[{"data":1,"prerenderedAt":1994},["ShallowReactive",2],{"career":3,"content":277},{"site":4,"profile":13,"skills":59,"selectedWork":111,"technologyAliases":136,"projects":137},{"headline":5,"seoDescription":11,"cv":12},[6,7,10],"I build systems that ",{"text":8,"emphasis":9},"hold under load","accent",".","Senior software engineer with over {years} years of experience. Go, Kubernetes and cloud-native technologies — soft real-time systems, asynchronous communications, and distributed applications.","Semir_Mahovkic-CV.pdf",{"name":14,"handle":15,"title":16,"location":17,"photo":18,"availability":19,"email":20,"website":21,"github":22,"linkedin":23,"languages":24,"ownership":28,"summary":33},"Semir Mahovkic","semir.mahovkic","Senior Software Engineer","Remote","\u002Fsemir-mahovkic.jpg","open","smahovkic89@gmail.com","https:\u002F\u002Fsemirmahovkic.xyz","https:\u002F\u002Fgithub.com\u002Fsemirm-dev","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fsemir-mahovkic-a42ba7135",[25,26,27],"go","csharp","typescript",[29,30,31,32],"code","build","deploy","monitor",[34,55],[35,36,39,40,42,43,45,46,48,49,51,52,54],"Building systems for more than {years} years, for fast-moving startups and large-scale enterprises — memory-efficient, low-latency (sub-millisecond) Go backends for ",{"text":37,"emphasis":38},"Tradeview Markets","strong",", Kubernetes Operators for ",{"text":41,"emphasis":38},"Cisco"," Secure Firewall Cloud Native, real-time data synchronization for ",{"text":44,"emphasis":38},"Sportradar",", Temporal-orchestrated order flows for ",{"text":47,"emphasis":38},"Daki","'s grocery delivery platform, embedded .NET inside ",{"text":50,"emphasis":38},"Agilent","'s laboratory instruments, self-service VM provisioning on ",{"text":53,"emphasis":38},"VMware","'s VCF Automation.",[56,58],{"text":57,"emphasis":9},"Cloud-native"," production systems, owned end-to-end — code, build, deploy, monitor.",[60,72,85,99],{"name":61,"icon":29,"technologies":62},"Languages & Core",[63,66,69],{"name":64,"level":65},"Go (Gin)",5,{"name":67,"level":68},"C# (.NET Core)",4,{"name":70,"level":71},"TypeScript",3,{"name":73,"icon":74,"technologies":75},"Messaging, Transport & Orchestration","messaging",[76,77,78,79,80,81,82,83,84],"gRPC (Streams)","Aeron","RabbitMQ","MQTT","Redis PubSub","AWS SQS\u002FSNS","WebSockets","FIX Protocol","Temporal (Workflows)",{"name":86,"icon":87,"technologies":88},"Cloud Native, DevOps & Observability","cloud",[89,90,91,92,93,94,95,96,97,98],"Kubernetes (Operators, CRDs)","Docker","Helm","ArgoCD (GitOps app-of-apps pattern)","Prometheus","Grafana","OpenTelemetry Collector","Loki","Jaeger","Keycloak IAM",{"name":100,"icon":101,"technologies":102},"Databases & Caching","database",[103,104,105,106,107,108,109,110],"PostgreSQL","MySQL","Redis","MongoDB","Elasticsearch","SQLite","Cassandra","ClickHouse",[112,119,125,130],{"project":41,"title":113,"summary":114,"highlights":115},"Secure Firewall Cloud Native","Led a team building Kubernetes Operators for Cisco's firewall and VPN. Designed an Operator that wires AWS Shield into newly deployed services, and tightened access control in Kubernetes RBAC and AWS IAM.",[116,117,118],"Go","gRPC","Kubernetes Operators",{"project":47,"title":120,"summary":121,"highlights":122},"Grocery Delivery Platform","Brazil's most popular grocery delivery app. Built the order flows in Go — create, cancel, fulfillment, payment — as Temporal workflows, so an order halfway through picking survives a restart instead of being dropped. Exposed the whole order cycle through the GraphQL API.",[116,123,124],"Temporal","GraphQL",{"project":126,"title":37,"summary":127,"highlights":128},"TVM","Designed and implemented a memory-efficient, low-latency (sub-millisecond) backend for an online trading platform with zero allocations in the hot path. Aeron for inter-service transport, FIX integration with multiple liquidity providers, end-to-end order execution.",[116,77,129],"FIX",{"project":50,"title":131,"summary":132,"highlights":133},"Chromatography Data System","Custom software for Agilent's laboratory instruments, used for chemical analysis — from fuel and oil quality testing to cancer research. .NET microservices deployed on Windows Embedded hardware inside the instruments.",[134,135],".NET","Windows Embedded",{"gRPC (streams)":117,"Temporal Cloud":123},[138,156,172,185,199,211,223,235,247,260],{"company":139,"title":140,"label":141,"location":142,"website":143,"project":144,"summary":145,"start":146,"end":147,"technologies":148,"responsibilities":154},"Imel d.o.o.","Software Engineer","Imel","Lukavac, Bosnia and Herzegovina","https:\u002F\u002Fwww.imel.ba\u002F","Android + backend","Logistics tracking and dental monitoring platforms — the Android apps and the backends and mobile APIs behind them.","2015-05","2017-08",[149,150,151,82,152,153,104,108],"PHP","Laravel","Linux","Java","Android",[155],"Developed PHP\u002FLaravel backends, mobile APIs, and Android applications for logistics and dental monitoring platforms.",{"company":157,"title":140,"location":158,"website":159,"project":160,"projectUrl":161,"client":162,"summary":163,"start":164,"end":165,"technologies":166,"responsibilities":170},"MOP","Sarajevo, Bosnia and Herzegovina","https:\u002F\u002Fmop.ba\u002F","NAGA","https:\u002F\u002Fnaga.com\u002Fen","Crypto Exchange Platform","Online crypto exchange platform.","2018-04","2019-03",[116,167,78,82,168,105,169,109,117],"Kubernetes","AWS","Postgres",[171],"Built real-time Go microservices and Kubernetes infrastructure for a crypto exchange platform, using RabbitMQ, gRPC, and WebSockets.",{"company":173,"title":140,"location":158,"website":174,"project":175,"projectUrl":176,"client":177,"summary":178,"start":179,"end":180,"technologies":181,"responsibilities":183},"Mistral","https:\u002F\u002Fwww.mistral.ba\u002F","MTVO","https:\u002F\u002Fwww.activationstation.org\u002Fpurpose","Masters of Void Organization","A virtual goods trading platform built around spiritual sessions, with each session tracked as it runs.","2019-04","2021-10",[134,70,168,82,105,182],"MSSQL",[184],"Developed real-time data synchronization backends using .NET, WebSockets, Redis, and AWS for virtual goods trading and session tracking.",{"company":186,"title":16,"location":187,"website":188,"project":126,"projectUrl":189,"client":37,"start":190,"end":191,"technologies":192,"responsibilities":193},"Evoila","Mainz, Germany","https:\u002F\u002Fwww.evoila.com\u002F","https:\u002F\u002Fwww.tvmarkets.com\u002Fen\u002F","2026-01","2026-07",[116,77,169,110,129],[194,195,196,197,198],"Developed a memory-efficient, low-latency, sub-millisecond online trading backend in Go with zero allocations in the hot paths.","Used a pre-allocated slot map and reusable buffers to avoid allocations on the order path.","Used sync.Pool for the buffers that encode inter-service messages.","Built the inter-service transport using the Aeron protocol.","Integrated multiple Liquidity Provider (LP) APIs for real-time automated order routing and execution.",{"company":200,"title":16,"location":201,"website":202,"project":50,"projectUrl":203,"client":131,"summary":204,"start":205,"end":206,"technologies":207,"responsibilities":208},"Endava","London, UK","https:\u002F\u002Fwww.endava.com\u002F","https:\u002F\u002Fwww.agilent.com\u002F","Custom software for Agilent's laboratory instruments, used for chemical analysis — from fuel and oil quality testing to cancer research.","2021-11","2022-07",[134,70,135],[209,210],"Developed .NET microservices deployed on Windows Embedded hardware inside laboratory instruments.","Packaged software to be installed on the laboratory instruments using WiX Toolset.",{"company":200,"title":16,"location":201,"website":202,"project":41,"projectUrl":212,"client":213,"summary":214,"start":205,"end":215,"technologies":216,"responsibilities":219},"https:\u002F\u002Faws.amazon.com\u002Fmarketplace\u002Fpp\u002Fprodview-3lsp4y4zoa6jw","Cisco Secure Firewall Cloud Native","SFCN — the firewall and VPN Cisco ships as an appliance, delivered instead as a Kubernetes service on AWS.","2022-10",[116,167,217,218,168,117,169,105],"Helm charts","ArgoCD",[220,221,222],"Led the engineering team developing Kubernetes Operators for SFCN to automate lifecycle, resource management, and security policies.","Designed an Operator that wires AWS Shield into newly deployed services.","Tightened access control in Kubernetes RBAC and AWS IAM.",{"company":200,"title":16,"location":201,"website":202,"project":47,"projectUrl":224,"client":120,"summary":225,"start":226,"end":227,"technologies":228,"responsibilities":231},"https:\u002F\u002Fplay.google.com\u002Fstore\u002Fapps\u002Fdetails?id=com.daki","Brazil's most popular grocery delivery app.","2022-11","2023-10",[116,229,124,106,230],"Temporal Cloud","GCP",[232,233,234],"Built the order flows in Go — create, cancel, fulfillment, payment — as Temporal workflows, so an order halfway through picking survives a restart instead of being dropped.","Added the compensation steps: an item that goes out of stock comes off the order or cancels it, and a payment that fails puts the reserved stock back.","Exposed the whole order cycle through the GraphQL API.",{"company":200,"title":16,"location":201,"website":202,"project":44,"projectUrl":236,"client":237,"summary":238,"start":239,"end":240,"technologies":241,"responsibilities":243},"https:\u002F\u002Fsportradar.com\u002F","AVLCR, Streaming and Channel Management","The rights and channel management platform behind Sportradar's streaming product — which sports, tournaments and territories each betting operator is licensed to show.","2023-11","2024-06",[116,242,124,104,105,167,168],"gRPC (streams)",[244,245,246],"Built the Go jobs that synchronized matches, tournaments, categories and competitors from Sportradar's sports data service, consumed over gRPC streams and reconciled into the platform's own database.","Added a sync state service that records the last applied update per feed, so a job resumes where it stopped instead of re-reading the stream.","Exposed media assets through the GraphQL API as presigned S3 URLs.",{"company":186,"title":248,"location":187,"website":188,"project":249,"client":250,"summary":251,"start":252,"end":253,"technologies":254,"responsibilities":255},"Senior Cloud Native Engineer","MFC","MultiFeedCenter","Third-party data feeds ingested from many providers and reconciled into a single stream for downstream consumers.","2024-07","2025-10",[134,167,217,218,230,78,106,104,169],[256,257,258,259],"Migrated the .NET feed services from Windows Server services to cloud-native workloads — containerised with Docker, packaged as Helm charts and run under Kubernetes.","Designed GitOps CD pipelines via ArgoCD using the app-of-apps pattern to automate and streamline multi-service Kubernetes deployments.","Deployed a complete Kubernetes observability stack (Grafana, Loki, Prometheus, Elasticsearch, OpenTelemetry Collector) for distributed tracing and monitoring.","Configured and integrated Keycloak IAM SSO across Grafana, ArgoCD, and backend services (.NET).",{"company":186,"title":261,"label":53,"location":187,"website":188,"project":262,"projectUrl":263,"client":264,"summary":265,"start":266,"end":267,"current":268,"technologies":269,"responsibilities":273},"Senior Software Engineer — Cloud & Infrastructure Automation","Broadcom Professional Services","https:\u002F\u002Ftechdocs.broadcom.com\u002Fus\u002Fen\u002Fvmware-cis\u002Fvcf\u002Fvcf-9-0-and-later\u002F9-0\u002Foverview-of-vmware-cloud-foundation-9\u002Fwhat-is-vmware-cloud-foundation-and-vmware-vsphere-foundation\u002Fvcf-automation-overview.html","Self-Service VM Provisioning on VCF Automation 9","VMware's private-cloud automation platform — a self-service catalog where customers request and provision their own VMs.","2025-11","2026-09",true,[70,270,271,272],"VCF Automation 9","vRealize Orchestrator","NetBox IPAM",[274,275,276],"Implemented a self-service VM provisioning catalog item on VCF Automation 9 (formerly Aria Automation) — a dynamic request form backed by 20+ Orchestrator actions, workflows, and configuration elements, driving Kubernetes-native VM deployment.","Automated IP address management against NetBox IPAM and vCenter — live IP availability, hostname generation with uniqueness validation, and event-driven IP registration after deployment (via VCF Automation subscriptions).","Improved shared, reusable TypeScript automation libraries and built a generic, strongly typed configuration layer on top of them.",{"title":278,"blog":279,"cadence":282,"tracks":285,"posts":364},"🌩 Go++ 🌩",{"title":280,"description":281},"Write Go like a pro","Short notes on writing Go for production, also posted on LinkedIn.",{"postsPerWeek":283,"note":284},2,"One track at a time, in order: {first} through {firstLast}, then {next}, and on to {last}. Each topic is introduced and finished before the next one starts. Two posts a week puts the run at {weeks} weeks.",[286,293,299,305,311,317,322,327,332,337,342,346,350,354,358],{"letter":287,"name":288,"label":289,"source":290,"thesis":291,"version":292},"A","Go coding guidelines","Coding guidelines","coding guidelines","Naming, interfaces, structs, errors and package layout. Go is not OOP: structs are not classes, interfaces are not Java interfaces, and an abstraction costs more than the duplication it removes until there are two of something.",1,{"letter":294,"name":295,"label":296,"source":297,"thesis":298,"version":292},"B","Concurrency patterns","Concurrency","concurrency patterns","Worker pools, pipelines, fan-out, semaphores and errgroup. Each pattern has a termination condition and a way it leaks, and choosing between them is a decision about how much of the machine the workload is allowed to take.",{"letter":300,"name":301,"label":302,"source":303,"thesis":304,"version":292},"C","Memory & allocations","Memory","memory optimization","What allocates and how to find out. Escape analysis, interface boxing, slice reuse and retention, pointer-free structs, pooling and struct layout — measured with -benchmem and pprof before any of it is worth doing.",{"letter":306,"name":307,"label":308,"source":309,"thesis":310,"version":292},"D","Web servers with Gin","Web servers","reference server","The wiring between a router and a process that survives deployment: server deadlines, graceful shutdown, middleware order, request context, one place where domain errors become status codes, and handlers tested without a port.",{"letter":312,"name":313,"label":314,"source":315,"thesis":316,"version":292},"E","Auth for SPAs","Auth","auth cheat sheet","Access tokens in memory, refresh tokens in an HttpOnly cookie, rotation with reuse detection, and where CSRF applies — organised by what an attacker gets rather than by the order the endpoints are called.",{"letter":318,"name":319,"label":117,"source":320,"version":292,"thesis":321},"F","gRPC and Protobuf","grpc","Protobuf schemas that evolve without breaking clients, deadlines that travel with every call, status codes instead of error strings, and interceptors for everything that is not the handler.",{"letter":323,"name":324,"label":324,"source":325,"thesis":326,"version":283},"G","Distributed transactions","saga architecture","Sagas between services: compensable and pivot steps, semantic locks with a lease, the three identifiers a distributed transaction carries, and running one in two languages on two orchestration engines: Temporal in Go and MassTransit in .NET.",{"letter":328,"name":329,"label":77,"source":330,"version":283,"thesis":331},"H","Aeron protocol","aeron protocol","Publications, subscriptions and the media driver: reliable messaging over UDP, unicast and multicast, and how a Go service talks to it.",{"letter":333,"name":334,"label":129,"source":335,"version":283,"thesis":336},"I","FIX protocol","fix protocol","FIX from the Go side: sessions, logon and heartbeats, sequence numbers and resend requests, and parsing tag=value messages without losing an order.",{"letter":338,"name":339,"label":339,"source":340,"version":283,"thesis":341},"J","OpenTelemetry","opentelemetry","Traces, metrics and logs from one SDK: spans that follow a request from service to service, context propagation in Go, and sampling.",{"letter":343,"name":118,"label":118,"source":344,"version":283,"thesis":345},"K","kubernetes operators","Custom resources and the reconcile loop: level-triggered controllers, status and conditions, finalizers and owner references, built with controller-runtime.",{"letter":347,"name":82,"label":82,"source":348,"version":283,"thesis":349},"L","websockets","Long-lived connections in a Go server: the upgrade, one reader and one writer per connection, ping\u002Fpong keepalives, slow clients, and shutting down without dropping messages.",{"letter":351,"name":79,"label":79,"source":352,"version":283,"thesis":353},"M","mqtt","Publish\u002Fsubscribe for devices: topics and wildcards, QoS 0, 1 and 2, retained messages, last will, and persistent sessions from a Go client.",{"letter":355,"name":78,"label":78,"source":356,"version":283,"thesis":357},"N","rabbitmq","Exchanges, queues and bindings, acknowledgements and prefetch, dead-letter queues, and publisher confirms, so a message is either delivered or known to have failed.",{"letter":359,"name":360,"label":361,"source":362,"version":283,"thesis":363},"O","SQS and SNS","SQS\u002FSNS","sqs and sns","SNS topics fanning out into SQS queues: visibility timeouts, at-least-once delivery and idempotent consumers, FIFO queues and dead-letter queues.",[365,415,454,484,517,540,559,589,619,690,723,741,787,831,876,923,966,995,1026,1054,1088,1116,1144,1172,1200,1228,1256,1284,1311,1336,1363,1388,1416,1441,1466,1496,1524,1554,1584,1614,1642,1672,1700,1733,1764,1798,1831,1862,1894,1929,1960],{"id":366,"track":367,"slug":368,"status":369,"body":370,"images":412,"url":414},"00","0","series-opener","drafted",[371,374,377,380,383,386,394,397,400,403,406,409],{"lines":372},[373],"7-8 years of Go, trading systems (TVM, sub-millisecond latency), Kubernetes Operators (Cisco SFCN), Temporal-orchestrated order flows that couldn't afford to drop a message (Daki grocery delivery), and many more.",{"lines":375},[376],"Most of what I know comes from production: small startups and large-scale enterprises.",{"lines":378},[379],"I've been keeping notes the whole time - coding guidelines, concurrency patterns, memory optimizations, auth, distributed transactions. They've been sitting in private gists doing nothing. Now I want to turn them from developer notes (read: alien language) into human-readable content.",{"lines":381},[382],"I'm going to publish them here. One idea at a time.",{"lines":384},[385],"The plan:",{"lines":387},[388,389,390,391,392,393],"- Go coding guidelines: what \"idiomatic\" means when you're the one maintaining it in two years","- Concurrency patterns: worker pools, pipelines, fan-out, semaphores - and how each one leaks","- Memory optimizations: what escapes to the heap, and how to stop guessing about it","- Web servers with Gin: timeouts, graceful shutdown, middleware order - the wiring that survives a deploy","- Auth for SPAs: where the tokens live, and what an attacker gets when one leaks","- gRPC and Protobuf: schemas that evolve without breaking clients, deadlines, and status codes that mean something",{"lines":395},[396],"Future topics: distributed transactions with sagas, Aeron protocol, FIX protocol, OpenTelemetry, Kubernetes Operators, WebSockets, MQTT, RabbitMQ, SQS\u002FSNS, and a few other advanced topics. All things I've actually worked with, not just read about.",{"lines":398},[399],"Later, I'll compile it all into one online collection, with the full details and working, real-world code examples.",{"lines":401},[402],"Twice a week, starting now. Consider this one the warm-up.",{"lines":404},[405],"Let's learn some Go.",{"lines":407,"question":268},[408],"Which of these are you most looking forward to?",{"lines":410},[411],"#golang #softwareengineering #backend",[413],"00-series-opener.png","https:\u002F\u002Flnkd.in\u002Fp\u002Fd9-HsU-u",{"id":416,"track":287,"slug":417,"status":418,"title":419,"date":420,"body":421,"images":451,"url":453},"A1","read-it-at-the-call-site","ready","Naming","2026-10-13",[422,425,429,432,438,441,445,448],{"lines":423},[424],"In Go, a name is good if it reads well.",{"lines":426},[427,428],"Names should be short and self-explanatory. Avoid stuttering and unnecessary suffixes (and prefixes).","Otherwise, you will have painful-to-read code with strange names that are hard to reason about.",{"lines":430},[431],"Everything except package names:",{"lines":433},[434,435,436,437],"- Length follows the scope. The further from its declaration a variable name is used, the more expressive it needs to be. It means, `i` is fine in short loop where you can see the declaration, but it is not fine two hundred lines later. ","- Do not stutter. Package name is part of the name. It means, `transport.TransportConfig` stutters, while `transport.Config` does not.","- Drop suffixes that bring no useful meaning -> `Impl`, `Base`, `Default`, etc.","- Use camelCase (unexported) and PascalCase (exported), even for constants. Not snake_case.",{"lines":439},[440],"Package names:",{"lines":442},[443,444],"- Lowercase, one word, no underscores.","- Package name should tell what it provides, not what data type or architecture layer it contains. `discounts` and `inventory` are acceptable, but `controllers` and `services` are (usually) not - more on this later.",{"lines":446,"question":268},[447],"Do your packages and types have good names? What are the main issues you encounter when naming packages?",{"lines":449},[450],"#golang #softwareengineering #cleancode",[452],"A1-read-it-at-the-call-site.png","https:\u002F\u002Flnkd.in\u002Fp\u002Fdctf-pdB",{"id":455,"track":287,"slug":456,"status":418,"title":457,"date":420,"body":458,"images":482},"A2","structs-are-not-classes","Structs",[459,463,466,469,474,477,480],{"lines":460},[461,462],"Structs are not classes. I am sorry to break it to you.","If you treat them like classes, they will become unmanageable god objects - and you will fight the system (your codebase) the whole time.",{"lines":464},[465],"A struct in Go is simply a state container that groups related things\u002Fdata together: connection pool, cache, order item. If there is no state to hold or data to group, you probably just need a plain function that takes its data from input arguments.",{"lines":467},[468],"Common mistakes I see developers bring from class-based (OOP) languages:",{"lines":470},[471,472,473],"- Setters and getters. If a field has no special logic, then export the field directly.","- Passing the entire struct just to read a field or two. If a function only needs `total` and `currency` from an `Order`, then pass those two values only - not the entire struct.","- The god object. One struct with a database, an HTTP client, a config and a logger. Just don't do that.",{"lines":475},[476],"Keep it simple!",{"lines":478,"question":268},[479],"How do you use structs in Go?",{"lines":481},[450],[483],"A2-structs-are-not-classes.png",{"id":485,"track":287,"slug":486,"status":418,"title":487,"date":420,"body":488,"images":515},"A3","the-consumer-owns-the-interface","Interfaces",[489,492,495,498,501,504,507,510,513],{"lines":490},[491],"Accept interfaces, return structs. This is by far the hardest one for new Go developers.",{"lines":493},[494],"They keep refusing it, without even trying it first. Then they complain about the code being hard to maintain.",{"lines":496},[497],"When you return an interface you loose the context. It hides every method that isn't on it, so the first caller who needs something new, adds it to the interface. Then the next caller adds one more. Eventually it becomes a bloated copy of the struct, and every mock in your tests has to implement all of it too.",{"lines":499},[500],"Define on demand, where it's used, literally next to the code that calls it.",{"lines":502},[503],"Add interface when you have two or more real implementations, or when you need to decouple from something you don't control (a payment API, a message broker, a cloud SDK) so it can be mocked in tests.",{"lines":505},[506],"Give the above two rules a try and see how it goes. You will be surprised how much better it is.",{"lines":508},[509],"Red flag: an interface and a struct that implements it in the same file - that's actually the same type written twice.",{"lines":511,"question":268},[512],"Where and why do you define interfaces in your codebase?",{"lines":514},[450],[516],"A3-the-consumer-owns-the-interface.png",{"id":518,"track":287,"slug":519,"status":418,"title":520,"date":420,"body":521,"images":538},"A4","handle-it-or-return-it","Error handling",[522,526,530,533,536],{"lines":523},[524,525],"Ideally, an error should be either handled (logged) or returned to the caller.","This is to avoid logging the same error three times in three different layers, so your dashboard does not count it three times - which looks confusing and misleading. It keeps your error stack trace clean and readable.",{"lines":527},[528,529],"To handle an error: log it, apply a retry policy, or whatever fits your use case and stop there - do not return it.","To return an error: add some context to it and pass it up - do not log it. Optionally (encouraged), wrap the error with `fmt.Errorf(\"user fetch failed: %w\", err)` so the original error is preserved and can be checked with `errors.Is(err, ...)`.",{"lines":531},[532],"This is also why Go error messages are lowercase with no punctuation at the end. They end up in the middle of another message, so it's \"user fetch failed: connection reset by peer\", not \"User fetch failed: Connection reset by peer.\"",{"lines":534,"question":268},[535],"How many times do you log errors in your services? Once, or once per layer?",{"lines":537},[450],[539],"A4-handle-it-or-return-it.png",{"id":541,"track":287,"slug":542,"status":418,"title":543,"date":420,"body":544,"images":557},"A5","stdlib-first","Dependencies",[545,548,552,555],{"lines":546},[547],"Prefer standard library over third-party packages, whenever possible and practical. Every external dependency you add is one more thing you have to maintain.",{"lines":549},[550,551],"Add third-party packages only when the standard library does not cover your use case, or when the third-party package is clearly worth it. Go standard library covers a lot, more than most of other languages.","Take the advantage of it.",{"lines":553,"question":268},[554],"What are the most common dependencies\u002Fpackages you add to your projects and why?",{"lines":556},[411],[558],"A5-stdlib-first.png",{"id":560,"track":287,"slug":561,"status":418,"title":562,"date":420,"body":563,"images":587},"A6","group-by-feature","Project layout",[564,568,572,575,578,581,584],{"lines":565},[566,567],"Organize your code by features, not by technical layers.","This probably matters more in Go than in other languages, because in Go a package is the directory, the namespace and the visibility boundary at the same time.",{"lines":569},[570,571],"If the top-level folders are `controllers\u002F`, `services\u002F` and `repositories\u002F`, you can't tell what the product actually does. It reads awkward and is confusing as well - remember the naming rules we previously discussed?","Avoid cross imports between features. If one feature needs another, it goes through a small client the other feature exposes, or a shared contract in `internal\u002F`.",{"lines":573},[574],"Each feature is like a slice from vertical-slices, and it owns its whole stack (routing, validation, business logic, persistence) in one directory, so changing one feature does not touch the other one - more on this in the next post.",{"lines":576},[577],"This turns to be very useful when a feature has to become its own service. Moving `discount\u002F` feature out is a package move and a few lines of wiring removed from `cmd\u002F`.",{"lines":579},[580],"Always ask yourself this question: If you had to move one feature into its own service tomorrow, how many directories would you touch?",{"lines":582,"question":268},[583],"How do you organize your codebase in your Go projects?",{"lines":585},[586],"#golang #softwarearchitecture #backend",[588],"A6-group-by-feature.png",{"id":590,"track":287,"slug":591,"status":418,"title":592,"date":420,"body":593,"images":617},"A7","inside-a-slice","Inside a feature\u002Fslice",[594,598,601,609,612,615],{"lines":595},[596,597],"Start your feature with one file. Add more files only when you actually need them.","Keep it simple and small.",{"lines":599},[600],"The best way to explain this is from an example, say order processing:",{"lines":602},[603,604,605,606,607,608],"- `order.go` first: types, domain rules and validation.","- `service.go` next: use cases and interfaces for external dependencies, like storage or payment API. No interface to wrap the service itself. Big no-no!","- `transport\u002Fhttp.go` for HTTP related code: encoding, decoding, perhaps some handlers, calling the service. Many times it's for Frontend communication.","- `transport\u002Fclient.go` when another feature needs to call this one: specialized typed client to expose some functionalities of this feature. Mainly for feature-to-feature communication (service-to-service).","- `repository\u002Fpostgres.go` for persistent storage implementation. It implements the interface that `service.go` declared.","- `cmd\u002F` wires it all together: build the repository, pass it to the service, map the rest. That's the only place they all meet.",{"lines":610},[611],"File names are only examples, you can name them whatever you want.",{"lines":613,"question":268},[614],"Which of these do you create on day one?",{"lines":616},[586],[618],"A7-inside-a-slice.png",{"id":620,"track":294,"slug":621,"status":418,"title":622,"date":420,"body":623,"images":688},"B1","three-golden-rules","Three golden rules",[624,628,631,636,639,642,645,648,651,654,657,660,665,669,672,675,679,682,685],{"lines":625},[626,627],"Avoid running a producer and consumer on the same goroutine. Always have a clear exit path.","Use `context.Context`.",{"lines":629},[630],"Before all the details, let's review the jargon first:",{"lines":632},[633,634,635],"- Producer is sender: goroutine that sends data to a channel.","- Consumer is receiver: goroutine that receives data from a channel.","- Caller: goroutine that starts the producer and consumer.",{"lines":637},[638],"The rules are easy to remember and they prevent many concurrency-related bugs, but they are often overlooked or ignored.",{"lines":640},[641],"#1: One side must already be running.",{"lines":643},[644],"A producer and its consumer should not run on the same goroutine. Otherwise it can deadlock:",{"lines":646},[647],"`fatal error: all goroutines are asleep - deadlock!`",{"lines":649},[650],"If the caller consumes from a channel, start the producers first. If the caller produces to a channel, start the consumers first. A big enough buffer also works, but it is not a rock solid solution, it works until you add more items than the buffer can hold.",{"lines":652},[653],"#2: Every goroutine needs a way to stop and exit. You must be 100% clear on this.",{"lines":655},[656],"Before you type `go`, decide what makes it return: a closed channel or a cancelled context. Without one, the goroutine leaks: it stays blocked and holds its memory until the program exits.",{"lines":658},[659],"Examples:",{"lines":661},[662,663,664],"Closed channel (by producer):","- close(ch) + select { case item, ok := \u003C-ch: if !ok { return } }","- for item := range ch { ... } return",{"lines":666},[667,668],"Cancelled context (by consumer):","- cancel() + select { case \u003C-ctx.Done(): return }",{"lines":670},[671],"#3: Context comes first.",{"lines":673},[674],"Any function that waits (I\u002FO, channel operations, etc.) should take `ctx context.Context` as its first argument, and return when the context is cancelled.",{"lines":676},[677,678],"Once we start discussing worker pools, fan-out, pipelines, semaphores, errgroup, etc., you will see that they all build on these three rules.","These are not the only rules and principles to follow, but they are more than enough to get you started.",{"lines":680},[681],"And don't forget to run your tests with `go test -race .\u002F...`",{"lines":683,"question":268},[684],"Do you always, with 100% certainty, know when your goroutines stop?",{"lines":686},[687],"#golang #concurrency #softwareengineering",[689],"B1-three-golden-rules.png",{"id":691,"track":294,"slug":692,"status":418,"title":693,"date":420,"body":694,"images":721},"B2","closing-channels","Closing channels",[695,698,701,704,707,710,713,716,719],{"lines":696},[697],"Producer closes the channel, consumer cancels the context.",{"lines":699},[700],"The safest way to close a channel is to close it from the producer side only. This tells consumers that the producer is finished and they can stop consuming. It saves you from panics like:",{"lines":702},[703],"- `panic: send on closed channel` -> sending to a closed channel panics.",{"lines":705},[706],"- `panic: close of closed channel` -> closing a closed channel also panics.",{"lines":708},[709],"When you have several producers, none of them closes. Let one coordinator do it: wait for every producer, then close the channel once (`wg.Wait()` + `close(ch)`).",{"lines":711},[712],"And if you need to stop a producer, cancel its context.",{"lines":714},[715],"A note: closing a channel does not throw away data that is still in the channel!",{"lines":717,"question":268},[718],"Have you ever experienced a `panic: send on closed channel`? Was it a consumer closing the channel, or a double close during shutdown?",{"lines":720},[687],[722],"B2-closing-channels.png",{"id":724,"track":294,"slug":725,"status":418,"title":726,"date":420,"body":727,"images":739},"B3","channels-vs-mutexes","Channels vs mutexes",[728,731,734,737],{"lines":729},[730],"Channels for coordination: passing data around. Mutexes for state: struct fields, cache maps, counters.",{"lines":732},[733],"Keep the locked section small. Do not do network I\u002FO, disk writes or channel sends while you hold a lock. Every other goroutine that needs the lock must wait until you release it.",{"lines":735,"question":268},[736],"Two goroutines share a map. Which do you reach for first, a channel or a mutex?",{"lines":738},[687],[740],"B3-channels-vs-mutexes.png",{"id":742,"track":294,"slug":743,"status":369,"title":744,"body":745,"images":780},"B4","worker-pool","Worker pool",[746,749,752,755,762,765,768,772,775,778],{"lines":747},[748],"A worker pool is a fixed number of goroutines, all pulling jobs from one channel.",{"lines":750},[751],"Picture a shop with three cashiers and one line. Ten customers come in, three get served and seven wait. Nobody skips ahead, and a fourth cashier never shows up.",{"lines":753},[754],"In Go you need two channels and a sync.WaitGroup. Here's who does what:",{"lines":756},[757,758,759,760,761],"- Worker: ranges over jobs and sends each result to results. When jobs is closed, the loop ends and the worker returns.","- Pool: starts N workers, with one wg.Add(1) each.","- Producer: sends all the jobs, then closes jobs. That close is what lets the workers stop.","- Coordinator: waits on wg, then closes results.","- Consumer: main ranges over results until it's closed.",{"lines":763},[764],"What really matters is who closes what. Each channel has exactly one closer, and it's whoever knows the sending is done. The producer closes jobs, the coordinator closes results, and main closes nothing.",{"lines":766},[767],"Two ways this goes wrong:",{"lines":769},[770,771],"- A worker closes results: the next worker that sends panics with \"send on closed channel\".","- Nobody closes jobs: the workers wait forever, wg.Wait() never returns, and main never finishes. In a service you just see a hang, with no error anywhere.",{"lines":773},[774],"Full example: https:\u002F\u002Fgist.github.com\u002Fsemirm-dev\u002Ffd3e5fee6cf2df12894de6089662c719#2-worker-pool-bounded-concurrency",{"lines":776,"question":268},[777],"In your last worker pool, which goroutine closed the results channel?",{"lines":779},[687],[781,782,783,784,785,786],"B4-worker-pool-1.png","B4-worker-pool-2.png","B4-worker-pool-3.png","B4-worker-pool-4.png","B4-worker-pool-5.png","B4-worker-pool-6.png",{"id":788,"track":294,"slug":789,"status":369,"title":790,"body":791,"images":825},"B5","fan-out-fan-in","Fan-out, fan-in",[792,795,798,801,807,810,813,817,820,823],{"lines":793},[794],"Fan-out spreads one input over several workers. Fan-in merges their results back into one channel.",{"lines":796},[797],"Picture a kitchen with three cooks and one rail of orders. Each cook grabs the next order and puts the finished dish on their own counter. One runner collects from all three counters and brings everything to the same window. Dishes come out in the order they're finished, not the order they were ordered.",{"lines":799},[800],"In Go that's one channel per worker, a fanIn function and a sync.WaitGroup:",{"lines":802},[803,804,805,806],"- Workers: N of them range over the same input, and each sends results to its own out. When input is closed, the loop ends and the worker closes its out.","- Producer: sends every item, then closes input. That close is what lets the workers stop.","- Fan-in: fanIn copies every out into one merged channel, waits on wg, then closes the merged channel.","- Consumer: main ranges over the merged channel until it's closed.",{"lines":808},[809],"Same closing rule as the worker pool: each channel has one closer, and it's whoever knows the sending is done. The producer closes input, each worker closes its own out, fanIn closes the merged channel, and main closes nothing.",{"lines":811},[812],"If the closer is wrong, you get one of these:",{"lines":814},[815,816],"- A worker never closes its out: fanIn waits on it forever, the merged channel never closes, and main never finishes. In a service that's a hang with no error.","- fanIn closes the merged channel without waiting on wg: the loop in main ends early with results missing, and the next result a worker forwards panics with \"send on closed channel\".",{"lines":818},[819],"Full example: https:\u002F\u002Fgist.github.com\u002Fsemirm-dev\u002Ffd3e5fee6cf2df12894de6089662c719#3-fan-out-fan-in-dynamic-concurrency",{"lines":821,"question":268},[822],"When the input can grow without limit, do you still fan out, or do you switch to a worker pool?",{"lines":824},[687],[826,827,828,829,830],"B5-fan-out-fan-in-1.png","B5-fan-out-fan-in-2.png","B5-fan-out-fan-in-3.png","B5-fan-out-fan-in-4.png","B5-fan-out-fan-in-5.png",{"id":832,"track":294,"slug":833,"status":369,"title":834,"body":835,"images":871},"B6","pipeline","Pipeline",[836,839,842,845,850,853,856,859,863,866,869],{"lines":837},[838],"A pipeline is a chain of stages. Each stage reads from the previous stage's channel and writes to its own.",{"lines":840},[841],"Think of a car wash. One car gets soaped while the next is rinsed and the one before it is dried. Each station does one thing and passes the car on, and nobody waits for the whole line to finish.",{"lines":843},[844],"In Go each stage is a function that takes a channel and returns a new one:",{"lines":846},[847,848,849],"- Source: where it starts. It sends every value to its own out, then closes it.","- Stage: ranges over in, sends each transformed value to its own out, and closes out once in is closed.","- Consumer: main connects the stages and ranges over the last channel until it's closed.",{"lines":851},[852],"Values move on as soon as they're ready, so main can print the first result while the source is still sending. And if the source reads its data piece by piece, like a file line by line, you never hold the whole dataset in memory.",{"lines":854},[855],"Closing is simple here: the stage that returns a channel is the one that closes it, usually with defer close(out). main closes nothing.",{"lines":857},[858],"Where it breaks:",{"lines":860},[861,862],"- A stage never closes its out: the next stage's loop never ends, and neither does main. In a service that's a hang with no error.","- The consumer stops early: every stage upstream blocks forever on a send nobody will receive, and those goroutines stay around for the life of the process. Pass a ctx into every stage, or always read to the end.",{"lines":864},[865],"Full example: https:\u002F\u002Fgist.github.com\u002Fsemirm-dev\u002Ffd3e5fee6cf2df12894de6089662c719#4-pipeline-stream-processing",{"lines":867,"question":268},[868],"When your consumer stops halfway, what tells the stages upstream to stop?",{"lines":870},[687],[872,873,874,875],"B6-pipeline-1.png","B6-pipeline-2.png","B6-pipeline-3.png","B6-pipeline-4.png",{"id":877,"track":294,"slug":878,"status":369,"title":879,"body":880,"images":917},"B7","counting-semaphore","Counting semaphore",[881,884,887,890,896,899,902,905,909,912,915],{"lines":882},[883],"A counting semaphore sits somewhere between a worker pool and fan-out. The work decides how many goroutines start, but only N run at once.",{"lines":885},[886],"Think of a car park with three spaces and a gate. Cars turn up whenever they like. The gate lets one in for each free space and the rest wait. A driver who gets tired of waiting can leave.",{"lines":888},[889],"In Go it's a buffered channel and two methods:",{"lines":891},[892,893,894,895],"- Limiter: a buffered channel with room for N tokens. Its capacity is your limit.","- Acquire: sends a token. If N are already taken, the send blocks. A ctx lets the caller give up instead of waiting forever.","- Release: takes one token back out, which frees a space.","- Caller: one goroutine per task. It calls Acquire, checks the error, then defer Release().",{"lines":897},[898],"It's useful when something outside your process has a limit: a third-party API, a database, the disk.",{"lines":900},[901],"The thing to get right is releasing only what you took. defer Release() goes on the line after a successful Acquire, never before the error check.",{"lines":903},[904],"Two common mistakes:",{"lines":906},[907,908],"- defer Release() before the error check: a caller that gave up still releases. It frees a token that belongs to someone else, so more tasks run than the limit allows. Later a release finds no token to take and blocks forever.","- A caller never releases: its space is gone for good. After N of those, every Acquire waits until its ctx gives up, and all new work gets rejected.",{"lines":910},[911],"Full example: https:\u002F\u002Fgist.github.com\u002Fsemirm-dev\u002Ffd3e5fee6cf2df12894de6089662c719#5-counting-semaphore--rate-limiting",{"lines":913,"question":268},[914],"When the limit is reached, should the caller wait or give up?",{"lines":916},[687],[918,919,920,921,922],"B7-counting-semaphore-1.png","B7-counting-semaphore-2.png","B7-counting-semaphore-3.png","B7-counting-semaphore-4.png","B7-counting-semaphore-5.png",{"id":924,"track":294,"slug":925,"status":369,"title":925,"body":926,"images":960},"B8","errgroup",[927,930,933,936,942,945,948,952,955,958],{"lines":928},[929],"errgroup is a WaitGroup that also deals with errors. The first failure cancels the rest, and Wait returns that error.",{"lines":931},[932],"Say three friends are booking one trip. One books the flight, one the hotel, one the car. If the flight falls through, there's no point booking the other two. One message tells everyone to stop, and why.",{"lines":934},[935],"In Go that's errgroup.WithContext and a context you pass down:",{"lines":937},[938,939,940,941],"- Group: errgroup.WithContext(ctx) gives you a group and a groupCtx. The first error cancels groupCtx.","- Task: g.Go starts a goroutine that returns an error.","- Call: takes groupCtx and stops when \u003C-ctx.Done() fires, instead of finishing work nobody needs any more.","- Wait: g.Wait() blocks until every task returns, then gives you the first error.",{"lines":943},[944],"For the cancel to work, every task has to take groupCtx and pass it all the way down. It only reaches code that's listening for it.",{"lines":946},[947],"Two ways to break it:",{"lines":949},[950,951],"- A task creates its own context.Background(): nothing can cancel it. When another task fails, this one keeps going, and g.Wait() waits for it anyway.","- You reuse groupCtx after g.Wait(): it's already cancelled, even if every task succeeded, so the next call fails straight away with \"context canceled\".",{"lines":953},[954],"Full example: https:\u002F\u002Fgist.github.com\u002Fsemirm-dev\u002Ffd3e5fee6cf2df12894de6089662c719#6-structured-concurrency-errgroupgroup",{"lines":956,"question":268},[957],"When one call fails, should the others stop, or should they finish?",{"lines":959},[687],[961,962,963,964,965],"B8-errgroup-1.png","B8-errgroup-2.png","B8-errgroup-3.png","B8-errgroup-4.png","B8-errgroup-5.png",{"id":967,"track":300,"slug":968,"status":369,"body":969,"imageSpec":994},"C1","tune-before-you-rewrite",[970,973,976,979,982,985,988,991],{"lines":971},[972],"Before you rewrite a single allocation site, try two environment variables.",{"lines":974},[975],"GOGC and GOMEMLIMIT change what your service spends on garbage collection, and neither one asks you to touch code.",{"lines":977},[978],"GOGC is the growth target. The default of 100 means the heap doubles before the next collection. Raising it to 200 or 400 trades resident memory for fewer GC cycles. If you have RAM spare and your CPU profile is full of gcBgMarkWorker, that’s a config change, not a refactor.",{"lines":980},[981],"GOMEMLIMIT is the soft ceiling, and it’s the one that matters in Kubernetes. Without it the collector has no idea your pod gets OOM-killed at 512Mi. With it, collection gets more aggressive as you approach the limit instead of walking off the edge.",{"lines":983},[984],"Then measure. go test -benchmem for the count, go tool pprof -alloc_space for where they come from.",{"lines":986},[987],"Only then rewrite. Every technique in this track — pooling, reslicing, unsafe conversions — buys less than those two variables did, and costs you code you maintain forever.",{"lines":989},[990],"Optimising allocations you never measured is making the code harder to read on purpose.",{"lines":992,"question":268},[993],"What actually moved the needle for you — a GOGC change, a GOMEMLIMIT, or a real rewrite?","Two lines of env config against a twelve-line pooling refactor, side by side, with the measured delta under each. The asymmetry is the argument.",{"id":996,"track":300,"slug":997,"status":369,"code":998,"body":1001},"C2","ask-the-compiler",{"caption":999,"source":1000},"go build -gcflags=\"-m -m\" — the compiler’s own answer","$ go build -gcflags=\"-m -m\" .\u002F...\n\n.\u002Fhandler.go:14:6:  can inline (*Buffer).Reset with cost 12\n.\u002Fhandler.go:22:16: inlining call to (*Buffer).Reset\n.\u002Fhandler.go:22:16: &Response{...} does not escape\n\n.\u002Fhandler.go:31:10: &Response{...} escapes to heap:\n.\u002Fhandler.go:31:10:   flow: ~r0 = &{storage for &Response{...}}\n.\u002Fhandler.go:31:10:   from return ~r0 (return) at .\u002Fhandler.go:31:3\n\n.\u002Fhandler.go:40:15: make([]byte, n) escapes to heap: non-constant size\n.\u002Fhandler.go:47:2:  moved to heap: buf",[1002,1005,1008,1011,1017,1020,1023],{"lines":1003},[1004],"You do not have to reason about what escapes. -gcflags=\"-m -m\" will tell you — and it accounts for inlining, which your reasoning will not.",{"lines":1006},[1007],"Escape analysis is not a rule you memorise. It is output.",{"lines":1009},[1010],"A value goes to the heap for five reasons: its address outlives the frame, a make size isn’t a compile-time constant, it exceeds the stack-variable limit, it sits behind an interface the compiler can’t devirtualize, or an escaping closure captures it by reference.",{"lines":1012},[1013,1014,1015,1016],"The limits are the two nobody knows:","— 64KB for implicit allocations — new, make, an addressed composite literal","— 128KB for an explicitly declared local","— past either, it is heap no matter how you use it",{"lines":1018},[1019],"The reason to read the output rather than the code is that half of these decisions do not survive optimisation. Inlining deletes the frame the address was outliving. Devirtualization deletes the interface. I have rewritten functions to avoid an escape the compiler had already removed, and got a slower function and a worse API for it.",{"lines":1021},[1022],"Build the flags into the benchmark loop, not into a code review argument. -m -m and -benchmem disagree with people, and they are usually right.",{"lines":1024,"question":268},[1025],"What was the last escape you were sure about that `-m -m` disagreed with?",{"id":1027,"track":300,"slug":1028,"status":369,"code":1029,"body":1032},"C3","any-allocates",{"caption":1030,"source":1031},"The boxing, the generic that avoids it, and the one that brings it back","var events = make(chan any, 1024)\n\nfunc Emit(v any) { events \u003C- v } \u002F\u002F v outlives the call: 1 alloc\n\ntype Queue[T any] struct{ ch chan T }\n\nfunc (q *Queue[T]) Emit(v T) { q.ch \u003C- v } \u002F\u002F T travels as itself: 0 allocs\n\nfunc Trace[T any](v T) {\n    fmt.Println(v) \u002F\u002F fmt takes ...any: back to 1 alloc\n}",[1033,1036,1039,1042,1045,1048,1051],{"lines":1034},[1035],"An any that escapes is a heap copy you did not ask for. Generics fix it, right up until T lands in an any again.",{"lines":1037},[1038],"The eface needs a pointer, so an escaping value is copied to the heap to give it one.",{"lines":1040},[1041],"That is the whole mechanism. An interface value is two words — a type and an unsafe.Pointer — and a concrete value living in a register or on the stack has no address worth pointing at. Keep the interface inside the call and the compiler uses a stack slot; send it to a channel, a field or a logger and it makes one on the heap.",{"lines":1043},[1044],"A type parameter does not do that. ProcessGeneric[T] receives the value itself, not an interface around it, so there is nothing to box and the allocation is gone.",{"lines":1046},[1047],"Then someone widens it back. Generics stop helping the moment T is assigned into an any or handed to fmt — which takes ...any and boxes every argument you give it.",{"lines":1049},[1050],"So the win is real and it is fragile. A generic hot path goes back to one allocation per call the moment a debug log at the bottom of the function passes v to fmt.Sprintf. The signature says generic. The profile says otherwise.",{"lines":1052,"question":268},[1053],"Have you profiled the generic version, or just trusted the signature?",{"id":1055,"track":300,"slug":1056,"status":369,"code":1057,"body":1060},"C4","the-0-255-trap",{"caption":1058,"source":1059},"One frame, two benchmarks","var sink any\n\n\u002F\u002F package vars: a local constant is folded and never allocates either\nvar small, large = 42, 91447\n\nfunc BenchmarkSmallInt(b *testing.B) {\n    b.ReportAllocs()\n    for b.Loop() {\n        sink = small \u002F\u002F 0 allocs\u002Fop — points at the runtime's static table\n    }\n}\n\nfunc BenchmarkRealInt(b *testing.B) {\n    b.ReportAllocs()\n    for b.Loop() {\n        sink = large \u002F\u002F 1 alloc\u002Fop — copied to the heap\n    }\n}",[1061,1064,1067,1070,1073,1076,1079,1082,1085],{"lines":1062},[1063],"Your allocation benchmark says zero. It’s lying to you.",{"lines":1065},[1066],"Boxing a value into an any normally copies it to the heap — the interface header needs an address to point at, and a stack address won’t survive.",{"lines":1068},[1069],"Except the runtime keeps a static table of the integers 0 through 255, and of every zero-size value. Box one of those and the interface points straight at the table entry. No copy. No allocation.",{"lines":1071},[1072],"So b.ReportAllocs() prints 0 allocs\u002Fop, you conclude your logger doesn’t box, and you move on.",{"lines":1074},[1075],"Then it runs in production with a user_id of 91447 and allocates on every call.",{"lines":1077},[1078],"Same function, same benchmark, one different input. One reports 0. One reports 1.",{"lines":1080},[1081],"The rule: benchmark boxing with values above 255, and with the shapes you actually pass — a struct, a float, a string built at runtime.",{"lines":1083},[1084],"This isn’t an obscure edge case. It’s the default state of nearly every quick benchmark anyone writes to check whether something allocates, because the number you reach for first is always a small one.",{"lines":1086,"question":268},[1087],"What else have you hit that only allocates outside the benchmark?",{"id":1089,"track":300,"slug":1090,"status":369,"code":1091,"body":1094},"C5","append-into-a-buffer-you-own",{"caption":1092,"source":1093},"Sprintf, and the same line with a buffer you own","\u002F\u002F 3 allocations per call, every call\nline := fmt.Sprintf(\"%d %q %s\", n, name, t.Format(time.RFC3339))\n\n\u002F\u002F buf is reused across calls: 0 allocations after the first growth\nbuf = buf[:0]\nbuf = strconv.AppendInt(buf, n, 10)\nbuf = append(buf, ' ')\nbuf = strconv.AppendQuote(buf, name)\nbuf = append(buf, ' ')\nbuf = t.AppendFormat(buf, time.RFC3339)\n\n\u002F\u002F typed attributes instead of ...any\nlogger.Info(\"processed event\",\n    zap.Int(\"user_id\", id),\n    zap.Int64(\"latency_ms\", dur.Milliseconds()))",[1095,1098,1101,1104,1107,1110,1113],{"lines":1096},[1097],"fmt.Sprintf in a hot path is three allocations you can replace with strconv.AppendInt.",{"lines":1099},[1100],"It boxes every argument into an any, allocates the result, and throws the buffer away.",{"lines":1102},[1103],"The Append* family takes the destination as its first argument and returns it grown. You own the buffer, you keep it between calls, and the formatting costs nothing after the first loop.",{"lines":1105},[1106],"The same rule runs through logging. logger.Info(\"processed\", \"user_id\", id) is a ...any call and boxes both. zap.Int(\"user_id\", id) carries the type in the field and does not.",{"lines":1108},[1109],"Where the interface genuinely cannot be removed and the value is large, pass &myStruct. It still escapes — but you move an 8-byte pointer instead of copying a 200-byte body on every call.",{"lines":1111},[1112],"The failure this fixes is not a slow function. It is a service whose p99 tracks the GC and nothing else: a structured logger on the request path, one Sprintf per field, allocating more than the handler it was describing.",{"lines":1114,"question":268},[1115],"What is on your hot path purely because it was the convenient way to format a number?",{"id":1117,"track":300,"slug":1118,"status":369,"code":1119,"body":1122},"C6","reuse-capacity-zero-the-tail",{"caption":1120,"source":1121},"Reuse capacity; zero the tail; clear before reslice","buf := make([]byte, 0, 1024)\nfor {\n    buf = buf[:0]              \u002F\u002F len 0, cap 1024, 0 allocations\n    buf = readNextChunk(buf)\n    process(buf)\n}\n\nfunc FilterInPlace(users []*User, keep func(*User) bool) []*User {\n    filtered := users[:0]\n    for _, u := range users {\n        if keep(u) {\n            filtered = append(filtered, u)\n        }\n    }\n    clear(users[len(filtered):]) \u002F\u002F drop refs the GC would still trace\n    return filtered\n}\n\nclear(userSlice)               \u002F\u002F clear operates on len — do it first\nuserSlice = userSlice[:0]      \u002F\u002F then reslice",[1123,1126,1129,1132,1135,1138,1141],{"lines":1124},[1125],"buf[:0] keeps the memory. If it held pointers, it also keeps everything they point at.",{"lines":1127},[1128],"The header drops to len 0. The backing array does not move — and neither do the pointers sitting past it.",{"lines":1130},[1131],"For a []byte that is the whole trick and it is free: allocate 1024 once, reslice to zero at the top of the loop, refill it. Zero allocations for the life of the process.",{"lines":1133},[1134],"For a slice of pointers it is a leak with a reset button on it. The elements past the new length are unreachable to you and perfectly reachable to the GC, so every one of them stays alive until the slot is overwritten.",{"lines":1136},[1137],"clear is the fix, and the order is not negotiable — clear operates on len, so it must run while the length still covers the elements you are dropping. Clear first, reslice second. Reversed, it clears nothing and compiles fine.",{"lines":1139},[1140],"The in-place filter has the same shape: filter into users[:0], then clear(users[len(filtered):]) before you return. Without that line a slice whose length reads 12 can still be holding thousands of dead structs alive.",{"lines":1142,"question":268},[1143],"Which of your reused slices holds pointers, and does anything zero the tail?",{"id":1145,"track":300,"slug":1146,"status":369,"code":1147,"body":1150},"C7","retention-and-the-third-index",{"caption":1148,"source":1149},"bytes.Clone for retention, the third index for aliasing","\u002F\u002F leaks: the 4-byte result pins the whole 10MB array\nfunc HeaderBad(largeBuf []byte) []byte { return largeBuf[:4] }\n\n\u002F\u002F copies 4 bytes, releases 10MB\nfunc Header(largeBuf []byte) []byte { return bytes.Clone(largeBuf[:4]) }\n\nsub := buf[0:4]     \u002F\u002F cap = cap(buf) - 0: caller's append writes into buf\nsub := buf[0:4:4]   \u002F\u002F cap = 4: caller's append must allocate",[1151,1154,1157,1160,1163,1166,1169],{"lines":1152},[1153],"Returning 4 bytes of a 10MB buffer keeps all 10MB alive. Handing out a sub-slice lets the caller scribble on your array.",{"lines":1155},[1156],"Both are the same fact: a slice is a window, and the window is not the memory.",{"lines":1158},[1159],"A slice header points into a backing array, and the GC frees backing arrays, not windows. Return largeBuf[:4] and the 4 bytes hold the 10MB. bytes.Clone is the fix — copy the bytes you meant to return and let the buffer go.",{"lines":1161},[1162],"The second hazard is capacity. buf[0:4] keeps the parent’s remaining capacity, so the caller’s next append writes into your array instead of allocating. The three-index form clamps it: buf[0:4:4] sets cap to 4, and append is forced to copy.",{"lines":1164},[1165],"Both bugs are invisible at small sizes. The retention one shows up as a heap profile where a parser holds every request body it has ever read, because one header value was returned as a sub-slice. The aliasing one shows up as data corrupted by a function that never touched it.",{"lines":1167},[1168],"If a value crosses an API boundary, clone it or clamp it. Those are the only two answers.",{"lines":1170,"question":268},[1171],"Which of your functions returns a sub-slice of a buffer it did not allocate?",{"id":1173,"track":300,"slug":1174,"status":369,"code":1175,"body":1178},"C8","pointer-free-structs",{"caption":1176,"source":1177},"Traced, and the same tree as an arena of indices","type Node struct {           \u002F\u002F traced: 2 pointers per node, every mark phase\n    Name     string\n    Children []*Node\n}\n\ntype Node struct {           \u002F\u002F noscan: the GC skips the whole arena\n    NameOff, NameLen uint32  \u002F\u002F offsets into one shared []byte\n    FirstChild, Next int32   \u002F\u002F indices into one shared []Node\n}\n\ntype Arena struct {\n    Names []byte\n    Nodes []Node\n}\n\nfunc (a *Arena) Name(n Node) string {\n    return string(a.Names[n.NameOff : n.NameOff+n.NameLen])\n}",[1179,1182,1185,1188,1191,1194,1197],{"lines":1180},[1181],"A struct with no pointers is a struct the GC walks straight past — and time.Time forfeits that, because it holds a *Location.",{"lines":1183},[1184],"The mark phase does not read a noscan object. It skips the whole allocation.",{"lines":1186},[1187],"The runtime marks any type with no pointer-shaped field as noscan. One field is enough to lose it: string, []byte, map, chan, func, any interface — and time.Time, which nobody counts because it looks like three numbers.",{"lines":1189},[1190],"The rewrite that buys it back is an arena of indices. Strings become an offset and a length into one shared []byte; child pointers become int32 indices into one shared []Node. The tree is still a tree. The GC now sees two allocations instead of a million.",{"lines":1192},[1193],"The same reasoning decides []User against []*User. A slice of values is one allocation and, if User is noscan, one object to scan. A slice of pointers is N allocations and N pointers the collector chases every mark phase.",{"lines":1195},[1196],"This is the one item on the list I would not do speculatively. It costs you readable field access and gives you nothing under a million elements. Above that, it is the difference between a 4ms mark phase and a 40ms one.",{"lines":1198,"question":268},[1199],"What is the largest slice of structs in your process, and does it hold a `time.Time`?",{"id":1201,"track":300,"slug":1202,"status":369,"code":1203,"body":1206},"C9","pool-is-a-throttle-not-a-cache",{"caption":1204,"source":1205},"A pool that stores pointers and refuses oversized buffers","const (\n    defaultSize = 4 * 1024\n    maxCapSize  = 64 * 1024\n)\n\nvar pool = sync.Pool{\n    New: func() any { b := make([]byte, 0, defaultSize); return &b },\n}\n\nfunc Get() *[]byte { return pool.Get().(*[]byte) } \u002F\u002F assume nothing about it\n\nfunc Put(bufPtr *[]byte) {\n    if cap(*bufPtr) > maxCapSize { \u002F\u002F let the GC take the oversized ones\n        return\n    }\n    *bufPtr = (*bufPtr)[:0]\n    pool.Put(bufPtr)\n}",[1207,1210,1213,1216,1219,1222,1225],{"lines":1208},[1209],"sync.Pool hands you whatever the last caller left in it, and drops everything within two GC cycles.",{"lines":1211},[1212],"It is not a cache. It keeps the allocator quiet under load, then loses your object between collections.",{"lines":1214},[1215],"Three rules make it safe. Assume nothing on Get — reset the object on the way in, not on the way out, because you cannot trust the last user to have done it. Clear pointer fields before Put, or the pool pins everything they reference for as long as it lives. And cap what you accept back.",{"lines":1217},[1218],"That last one is the leak nobody expects. One 40MB request grows a pooled buffer, the buffer goes back in, and under steady traffic that 40MB keeps getting handed back out — the GC only reclaims it after two cycles nobody calls Get. Drop anything over a ceiling and let the GC have it.",{"lines":1220},[1221],"Store *[]byte, not []byte. Put takes an any, so a bare slice boxes its 24-byte header on every call and you have built an allocator that allocates. Staticcheck flags it as SA6002.",{"lines":1223},[1224],"And because objects survive at most two GC cycles — one live list, one victim cache — a pool is worth reaching for when allocation rate is the problem. It is never worth reaching for when initialisation cost is.",{"lines":1226,"question":268},[1227],"Does your pool have a cap ceiling, or is it holding the largest request it has ever seen?",{"id":1229,"track":300,"slug":1230,"status":369,"code":1231,"body":1234},"C10","before-you-reach-for-unsafe",{"caption":1232,"source":1233},"The four free cases, then the one with a rule attached","\u002F\u002F no allocation: the compiler elides all four\nv, ok := m[string(b)]\nif string(b) == \"ping\" { }\nswitch string(b) { case \"get\", \"put\": }\nfor _, r := range string(b) { _ = r }\n\n\u002F\u002F only after the above: b must NEVER be mutated afterwards\nfunc BytesToString(b []byte) string {\n    if len(b) == 0 {\n        return \"\"\n    }\n    return unsafe.String(unsafe.SliceData(b), len(b))\n}\n\nvar sb strings.Builder\nsb.Grow(total)                              \u002F\u002F 1 allocation\nfor _, s := range parts { sb.WriteString(s) } \u002F\u002F 0\nout := sb.String()                          \u002F\u002F 0, zero-copy view",[1235,1238,1241,1244,1247,1250,1253],{"lines":1236},[1237],"The compiler already elides the conversion in m[string(b)]. Check the four cases before you import unsafe.",{"lines":1239},[1240],"Most byte-to-string conversions on a hot path are already free. You have not checked which ones.",{"lines":1242},[1243],"There are four the compiler handles for you: a map index m[string(b)], a comparison string(b) == \"x\", a switch string(b), and range string(b). In all four the string never outlives the expression, so no copy is made. Restructure your call into one of these shapes and you are done.",{"lines":1245},[1246],"If you are still allocating, unsafe.String(unsafe.SliceData(b), len(b)) gives you a header pointing at the same bytes. The rule attached to it is absolute: b must never be mutated afterwards, because you have just handed out an immutable value backed by mutable memory.",{"lines":1248},[1249],"The reverse is worse. A string can live in .rodata, so writing through a string-to-bytes conversion is a SIGSEGV in production and works fine in your test, where the string came from a request.",{"lines":1251},[1252],"strings.Builder covers most of what people reach for unsafe to get: Grow once, write, and String() is a zero-copy view of its private buffer. Note that Grow is per-builder — Reset() discards the buffer, so a recycled Builder pays for it again. Pool a bytes.Buffer if you want the memory back.",{"lines":1254,"question":268},[1255],"Which of your `unsafe` string conversions is load-bearing, and which one is a habit?",{"id":1257,"track":300,"slug":1258,"status":369,"code":1259,"body":1262},"C11","struct-padding",{"caption":1260,"source":1261},"24 bytes and 16 bytes, same three fields","type BadStruct struct { \u002F\u002F unsafe.Sizeof == 24\n    a bool  \u002F\u002F offset 0,  + 7 padding\n    b int64 \u002F\u002F offset 8\n    c bool  \u002F\u002F offset 16, + 7 padding\n}\n\ntype GoodStruct struct { \u002F\u002F unsafe.Sizeof == 16\n    b int64 \u002F\u002F offset 0\n    a bool  \u002F\u002F offset 8\n    c bool  \u002F\u002F offset 9,  + 6 padding\n}\n\n\u002F\u002F $ fieldalignment .\u002F...\n\u002F\u002F order.go:14:17: struct of size 24 could be 16",[1263,1266,1269,1272,1275,1278,1281],{"lines":1264},[1265],"Three fields, 24 bytes, 14 of them dead. Reorder and it is 16.",{"lines":1267},[1268],"Nothing in the declaration says so. The compiler will not warn you, and the field order that reads best is usually the one that pads worst.",{"lines":1270},[1271],"Every field aligns to its own natural alignment — 1 for bool, 4 for int32, 8 for int64 and pointers on 64-bit — and the struct pads out to a multiple of its largest field’s alignment. A bool before an int64 costs 7 bytes of nothing.",{"lines":1273},[1274],"Order fields widest to narrowest and the holes collapse into one, at the end. fieldalignment .\u002F... finds them; fieldalignment -fix .\u002F... rewrites them, and drops your field comments while doing it — read that diff before you commit it.",{"lines":1276},[1277],"This is only worth doing where the type is instantiated in huge volumes. 8 bytes on a config struct is nothing. 8 bytes on 20 million order rows is 160MB of resident memory and a proportionally longer scan.",{"lines":1279},[1280],"Check it with unsafe.Sizeof and unsafe.Offsetof rather than counting by hand. Reordering for its own sake, on a struct you allocate twice, is churn in a diff.",{"lines":1282,"question":268},[1283],"Have you ever measured a struct in your hot path, or only the slice holding it?",{"id":1285,"track":306,"slug":1286,"status":369,"code":1287,"body":1290},"D1","graceful-shutdown",{"caption":1288,"source":1289},"Graceful shutdown, the whole of it","func main() {\n\tr := gin.Default()\n\tsrv := &http.Server{Addr: \":8080\", Handler: r}\n\n\tctx, stop := signal.NotifyContext(context.Background(),\n\t\tsyscall.SIGINT, syscall.SIGTERM)\n\tdefer stop()\n\n\tgo func() {\n\t\terr := srv.ListenAndServe()\n\t\tif err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\tlog.Fatalf(\"listen: %v\", err)\n\t\t}\n\t}()\n\n\t\u003C-ctx.Done()\n\tstop() \u002F\u002F a second signal now kills the process\n\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)\n\tdefer cancel()\n\tif err := srv.Shutdown(shutdownCtx); err != nil {\n\t\tlog.Printf(\"forced close: %v\", err)\n\t}\n}",[1291,1294,1297,1300,1305,1308],{"lines":1292},[1293],"r.Run() is for demos. Anything that gets deployed needs Shutdown(ctx).",{"lines":1295},[1296],"It blocks until the process dies. When SIGTERM arrives, every in-flight request dies with it.",{"lines":1298},[1299],"Build the http.Server yourself, run ListenAndServe in a goroutine, and wait on signal.NotifyContext. On the signal, call srv.Shutdown(ctx): the listener closes, idle keep-alive connections are closed, and open requests get until the deadline to finish.",{"lines":1301},[1302,1303,1304],"— The shutdown timeout must sit under terminationGracePeriodSeconds, or the kubelet SIGKILLs you mid-drain.","— The pod keeps receiving traffic for a second or two after SIGTERM, while the endpoint removal propagates to every node.","— So fail readiness first, sleep past that window, then shut down.",{"lines":1306},[1307],"Without it, a rolling deploy is a burst of 502s at the load balancer — one for every connection that was mid-request when the process exited. Ten pods, a handful of requests each, and nothing in the logs, because the process that would have logged them is gone.",{"lines":1309,"question":268},[1310],"What is your shutdown timeout, and did you pick it from your p99 handler latency or from the tutorial you copied?",{"id":1312,"track":306,"slug":1313,"status":369,"code":1314,"body":1317},"D2","server-timeouts",{"caption":1315,"source":1316},"Two servers, because streaming wants different limits","api := &http.Server{\n\tAddr:              \":8080\",\n\tHandler:           r,\n\tReadHeaderTimeout: 5 * time.Second,\n\tReadTimeout:       15 * time.Second,\n\tWriteTimeout:      30 * time.Second,\n\tIdleTimeout:       60 * time.Second,\n\tMaxHeaderBytes:    1 \u003C\u003C 20,\n}\n\n\u002F\u002F Long-lived responses: no WriteTimeout, bound the handler instead.\nstream := &http.Server{\n\tAddr:              \":8081\",\n\tHandler:           streamRouter,\n\tReadHeaderTimeout: 5 * time.Second,\n\tIdleTimeout:       120 * time.Second,\n}",[1318,1321,1324,1327,1330,1333],{"lines":1319},[1320],"Read, write and idle timeouts all default to zero, and zero means never.",{"lines":1322},[1323],"A client that opens a socket and dribbles one header byte a minute holds a goroutine and an fd for as long as it likes.",{"lines":1325},[1326],"Set four values on http.Server. ReadHeaderTimeout bounds the slowest thing a stranger can do to you. ReadTimeout bounds the body. IdleTimeout bounds keep-alive. WriteTimeout bounds the response — and it runs from the end of the request headers, so the handler’s own work counts against it.",{"lines":1328},[1329],"Slow loris is not exotic. A few thousand dribbling connections and the accept loop is still healthy; the process is simply out of descriptors, and every new request fails at accept, not in a handler.",{"lines":1331},[1332],"WriteTimeout bites from the other side. Put SSE or long polling behind a 30s write timeout and the stream dies at 30 seconds, every time, and the client reconnects into the same wall. Streaming gets its own server with WriteTimeout at zero and a deadline inside the handler.",{"lines":1334,"question":268},[1335],"Which of the four is set on the service you shipped last — and is `ReadHeaderTimeout` among them?",{"id":1337,"track":306,"slug":1338,"status":369,"code":1339,"body":1342},"D3","middleware-order",{"caption":1340,"source":1341},"Registration order is wrapping order","func newRouter(h *Handler) *gin.Engine {\n\tr := gin.New()\n\n\tr.Use(RequestID())              \u002F\u002F outermost: IDs everything below\n\tr.Use(gin.Logger())             \u002F\u002F sees the status recovery produced\n\tr.Use(gin.Recovery())           \u002F\u002F panic -> 500, inside the logger\n\tr.Use(Timeout(5 * time.Second)) \u002F\u002F bounds the handler\n\n\tv1 := r.Group(\"\u002Fv1\", Auth()) \u002F\u002F last: 401s are logged, with an ID\n\tv1.GET(\"\u002Forders\u002F:id\", h.GetOrder)\n\treturn r\n}\n\nfunc RequestID() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tid := c.GetHeader(\"X-Request-ID\")\n\t\tif id == \"\" {\n\t\t\tid = uuid.NewString()\n\t\t}\n\t\tc.Set(\"request_id\", id)\n\t\tc.Writer.Header().Set(\"X-Request-ID\", id)\n\t\tc.Next()\n\t}\n}",[1343,1346,1349,1352,1357,1360],{"lines":1344},[1345],"Middleware order is semantics, not taste: get it wrong and the request that panicked never reaches your access log.",{"lines":1347},[1348],"r.Use() builds an onion. What you register first wraps everything after it.",{"lines":1350},[1351],"A panic unwinds through c.Next(), so every line after it is skipped. Recovery must sit inside the logger, not outside it — register recovery first and the access log has no row for the request that panicked. gin.Default() has this right: Logger(), then Recovery().",{"lines":1353},[1354,1355,1356],"— Request ID outermost: everything below it, panics included, can name the request.","— Logger, then recovery: recovery turns the panic into a 500 the logger is still around to record.","— Timeout, then auth: a 401 should still be logged and still carry an ID.",{"lines":1358},[1359],"Backwards, the failure is silent in the way that costs the most. The handler panics, the client gets a bare 500, and the access log has no entry at all. What is left is a stack dump with nothing to join it to — no path, no user, no correlation with the ticket.",{"lines":1361,"question":268},[1362],"Where does auth sit in your chain — above the logger, or below it where the 401s still get logged?",{"id":1364,"track":306,"slug":1365,"status":369,"code":1366,"body":1369},"D4","bind-and-validate-at-the-edge",{"caption":1367,"source":1368},"The DTO stops here","type createOrderRequest struct {\n\tSymbol   string  `json:\"symbol\"   binding:\"required,uppercase,len=6\"`\n\tSide     string  `json:\"side\"     binding:\"required,oneof=buy sell\"`\n\tQuantity float64 `json:\"quantity\" binding:\"required,gt=0\"`\n}\n\nfunc (h *Handler) CreateOrder(c *gin.Context) {\n\tvar req createOrderRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t\u002F\u002F Below this line the transport does not exist.\n\torder, err := h.orders.Place(c.Request.Context(), domain.Order{\n\t\tSymbol: req.Symbol,\n\t\tSide:   domain.Side(req.Side),\n\t\tQty:    req.Quantity,\n\t})\n\tif err != nil {\n\t\tfail(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusCreated, toResponse(order))\n}",[1370,1373,1376,1379,1382,1385],{"lines":1371},[1372],"The handler is the only place a request becomes a domain type.",{"lines":1374},[1375],"Bind, validate, convert. Below the handler, a createOrderRequest should not be in scope.",{"lines":1377},[1378],"ShouldBindJSON for the body, ShouldBindUri for path parameters, ShouldBindQuery for the query string. The binding: tags are validator\u002Fv10: required, oneof, gt, min, dive. A bind error is a 400 and stops there — it never reaches a service.",{"lines":1380},[1381],"The DTO dies at the boundary. The service takes domain.Order, so it cannot be handed a side that was never checked and it has no opinion about JSON. That is also what lets the same service sit behind a gRPC entry point without a line changing.",{"lines":1383},[1384],"Skip the boundary and validation spreads. Six months later Quantity > 0 is enforced in the handler, again in the service and again in the repository, and the three disagree about what happens at exactly zero. The bug is not the duplicated check — it is that nobody can say which one is authoritative.",{"lines":1386,"question":268},[1387],"Do your service methods take DTOs — and if you deleted the JSON tags tomorrow, what would stop compiling?",{"id":1389,"track":306,"slug":1390,"status":369,"code":1391,"body":1394},"D5","request-context-propagation",{"caption":1392,"source":1393},"The context to pass, and the one to copy","func (h *Handler) GetOrder(c *gin.Context) {\n\t\u002F\u002F Cancelled when the client hangs up or the request times out.\n\tctx := c.Request.Context()\n\n\torder, err := h.orders.Get(ctx, c.Param(\"id\"))\n\tif err != nil {\n\t\tfail(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, toResponse(order))\n\n\t\u002F\u002F Outliving the request: copy the gin context, detach the deadline.\n\tcp := c.Copy()\n\tgo func() {\n\t\tbg, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)\n\t\tdefer cancel()\n\t\th.audit.Record(bg, cp.ClientIP(), order.ID)\n\t}()\n}",[1395,1398,1401,1404,1407,1410,1413],{"lines":1396},[1397],"Passing *gin.Context into your service couples business logic to a transport you will replace.",{"lines":1399},[1400],"It satisfies context.Context, so it compiles. It also carries the ResponseWriter.",{"lines":1402},[1403],"c.Request.Context() is the context you pass down. It is cancelled when the client disconnects or the server times the request out, and that cancellation reaches your database driver and your outbound HTTP calls for nothing.",{"lines":1405},[1406],"*gin.Context does not do that by default: Done() returns nil unless engine.ContextWithFallback is set. Code that looks cancellable never cancels.",{"lines":1408},[1409],"One exception, in the other direction. Gin reuses the *gin.Context after the handler returns, so touching it from a goroutine reads another request’s data. c.Copy() first, and give that goroutine a context that is meant to outlive the request.",{"lines":1411},[1412],"A client that abandons a request after 200ms leaves the query running for its full 30 seconds, holding a pool connection the whole time. Put a retrying caller in front of that and a slow endpoint becomes an exhausted pool, which is an outage on every other endpoint too.",{"lines":1414,"question":268},[1415],"Does anything below your handler package import `gin` — and how far would the fix reach if you deleted that import?",{"id":1417,"track":306,"slug":1418,"status":369,"code":1419,"body":1422},"D6","one-place-for-error-mapping",{"caption":1420,"source":1421},"One mapper, every handler","var (\n\tErrNotFound = errors.New(\"not found\")\n\tErrConflict = errors.New(\"conflict\")\n\tErrInvalid  = errors.New(\"invalid input\")\n)\n\nfunc fail(c *gin.Context, err error) {\n\tstatus, msg := http.StatusInternalServerError, \"internal error\"\n\tswitch {\n\tcase errors.Is(err, ErrNotFound):\n\t\tstatus, msg = http.StatusNotFound, \"not found\"\n\tcase errors.Is(err, ErrInvalid):\n\t\tstatus, msg = http.StatusBadRequest, \"invalid input\"\n\tcase errors.Is(err, ErrConflict):\n\t\tstatus, msg = http.StatusConflict, \"already exists\"\n\t}\n\n\tid := c.GetString(\"request_id\")\n\tlog.Printf(\"request_id=%s status=%d err=%v\", id, status, err)\n\tc.AbortWithStatusJSON(status, gin.H{\"error\": msg, \"request_id\": id})\n}",[1423,1426,1429,1432,1435,1438],{"lines":1424},[1425],"Domain error to HTTP status belongs in one function, not thirty c.JSON(400, ...) calls.",{"lines":1427},[1428],"Thirty call sites is thirty chances to leak a driver error to a client, and thirty envelopes that almost match.",{"lines":1430},[1431],"Domain packages return sentinel errors and wrap with %w. Handlers do not choose status codes. They hand the error to one function, and that function decides the code, the body and what gets logged.",{"lines":1433},[1434],"One function means one envelope for every endpoint, one place that knows a conflict is a 409, and one place where err.Error() goes to the log and a fixed string goes to the response. The client sees a category. The log keeps the cause.",{"lines":1436},[1437],"Leak it instead and a 500 body reads pq: duplicate key value violates unique constraint \"users_email_key\". That is a table name, a column name and a confirmation that the address is already registered, handed to an unauthenticated caller who was probing for exactly that.",{"lines":1439,"question":268},[1440],"Does every endpoint in your API return the same error shape, or does the client ship two parsers?",{"id":1442,"track":306,"slug":1443,"status":369,"code":1444,"body":1447},"D7","test-handlers-without-a-server",{"caption":1445,"source":1446},"A handler test with no network in it","func TestGetOrder(t *testing.T) {\n\tt.Parallel()\n\tgin.SetMode(gin.TestMode)\n\n\tr := newRouter(&Handler{orders: &stubOrders{\n\t\torder: domain.Order{ID: \"o-1\", Symbol: \"BTCUSD\"},\n\t}})\n\n\treq := httptest.NewRequest(http.MethodGet, \"\u002Fv1\u002Forders\u002Fo-1\", nil)\n\treq.Header.Set(\"X-Request-ID\", \"test-1\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusOK)\n\t}\n\tvar got orderResponse\n\tif err := json.NewDecoder(w.Body).Decode(&got); err != nil {\n\t\tt.Fatalf(\"decode: %v\", err)\n\t}\n\tif got.Symbol != \"BTCUSD\" {\n\t\tt.Errorf(\"symbol = %q, want %q\", got.Symbol, \"BTCUSD\")\n\t}\n}",[1448,1451,1454,1457,1460,1463],{"lines":1449},[1450],"httptest.NewRecorder(). No port, no sleep, no flake.",{"lines":1452},[1453],"A gin router is an http.Handler. ServeHTTP is a function call — testing one needs no listener.",{"lines":1455},[1456],"gin.SetMode(gin.TestMode) to quiet the debug output, build the router with fakes, httptest.NewRequest going in, httptest.NewRecorder coming out, then assert on w.Code and w.Body.",{"lines":1458},[1459],"What that buys is the absence of things. No port to allocate, no time.Sleep waiting for a server to be ready, no cleanup closing a listener. The whole file can run t.Parallel() and it stays green on a machine that already has something on 8080.",{"lines":1461},[1462],"The version with a real server fails once a week in CI and always on the loaded runner: address already in use, or the sleep was 50ms and the process needed 60. The test that failed is never the code that broke, and the fix is always to raise the sleep, so the suite gets slower and no more honest.",{"lines":1464,"question":268},[1465],"Is your handler test starting a listener — and what does that catch that `ServeHTTP` does not?",{"id":1467,"track":312,"slug":1468,"status":369,"code":1469,"body":1472},"E1","two-tokens-two-jobs",{"caption":1470,"source":1471},"Login response — the two tokens leave by different doors","func (h *Handler) login(w http.ResponseWriter, r *http.Request) {\n\tvar creds struct{ Email, Password string }\n\tif err := json.NewDecoder(r.Body).Decode(&creds); err != nil {\n\t\thttp.Error(w, \"bad request\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tuser, err := h.users.Authenticate(r.Context(), creds.Email, creds.Password)\n\tif err != nil {\n\t\thttp.Error(w, \"invalid credentials\", http.StatusUnauthorized)\n\t\treturn\n\t}\n\n\t\u002F\u002F access: a 15-minute JWT; refresh: opaque, stored only as a hash\n\taccess, refresh, err := h.tokens.Issue(r.Context(), user.ID)\n\tif err != nil {\n\t\thttp.Error(w, \"internal error\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\thttp.SetCookie(w, &http.Cookie{\n\t\tName: \"refresh\", Value: refresh, Path: \"\u002Fauth\u002Frefresh\",\n\t\tHttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,\n\t\tExpires: time.Now().Add(30 * 24 * time.Hour),\n\t})\n\twriteJSON(w, map[string]any{\"access_token\": access, \"user\": user})\n}",[1473,1476,1479,1482,1485,1490,1493],{"lines":1474},[1475],"Access token in memory, refresh token in an HttpOnly cookie. Everything else follows from that.",{"lines":1477},[1478],"One is handed out constantly. The other mints them. Store them the same way and you have one credential, not two.",{"lines":1480},[1481],"The access token is a JWT, 5–15 minutes, sent as Authorization: Bearer …, held in SPA memory only. No localStorage, no sessionStorage. It is gone on reload, and that is correct.",{"lines":1483},[1484],"The refresh token is opaque, long-lived, and set as HttpOnly; Secure; SameSite=Lax; Path=\u002Fauth\u002Frefresh. It does exactly one job — mint access tokens — and it is rotated on every use.",{"lines":1486},[1487,1488,1489],"— The backend stores refresh token hashes, never plaintext.","— The backend stores no access tokens at all: signature and exp are the check.","— A reload drops the access token; one POST \u002Fauth\u002Frefresh gets a new one.",{"lines":1491},[1492],"The split is what makes theft bounded. A stolen access token is dead in fifteen minutes. A stolen refresh token has to get past HttpOnly first, and script cannot read it — which bounds the blast radius, it does not guarantee the token cannot leak some other way.",{"lines":1494,"question":268},[1495],"Is your access token in memory, or is it in `localStorage` because the reload was annoying?",{"id":1497,"track":312,"slug":1498,"status":369,"body":1499,"imageSpec":1523},"E2","not-localstorage",[1500,1503,1506,1509,1512,1517,1520],{"lines":1501},[1502],"XSS reads your localStorage. The question is not whether it can — it is how long the damage lasts.",{"lines":1504},[1505],"HttpOnly keeps the refresh token out of reach of script. That bounds the damage. It does not stop the attack.",{"lines":1507},[1508],"Be exact about what an injected script gets in this design. It can read the access token out of memory, call your API as the user for as long as that token lives, and trigger refresh or logout from the page it is already on.",{"lines":1510},[1511],"It cannot read the HttpOnly refresh cookie, and it cannot walk away with a credential that outlives the session.",{"lines":1513},[1514,1515,1516],"— The compromise is temporary, not persistent.","— That is the entire security benefit of the split.","— It is not an XSS mitigation, and calling it one is how teams stop looking.",{"lines":1518},[1519],"The mitigations are a separate discipline and none of them live in your auth code: escape output, avoid raw HTML, ship a CSP with no unsafe-inline and no eval. Bounded blast radius on a page that renders user input through innerHTML is still a compromised page — the attacker just has to stay on it.",{"lines":1521,"question":268},[1522],"Does your CSP still carry `unsafe-inline` because a charting library asked for it?","A blast-radius diagram. A dashed box labelled ‘what the injected script reaches’ contains: in-memory access token, API calls as the user for ≤15 min, POST \u002Fauth\u002Frefresh, POST \u002Fauth\u002Flogout. Outside the box, greyed and struck through: the HttpOnly refresh cookie, and a long-lived credential to exfiltrate. A caption under the box reads ‘temporary, not persistent’.",{"id":1525,"track":312,"slug":1526,"status":369,"code":1527,"body":1530},"E3","rotate-and-detect-reuse",{"caption":1528,"source":1529},"Rotation with reuse detection","func (s *Store) Rotate(ctx context.Context, presented string) (string, error) {\n\trec, err := s.lookup(ctx, presented) \u002F\u002F by selector, compares verifier hash\n\tif err != nil {\n\t\treturn \"\", ErrUnknownToken\n\t}\n\tif rec.UsedAt != nil || rec.RevokedAt != nil {\n\t\t\u002F\u002F Someone replayed a token we already rotated away.\n\t\tif err := s.RevokeFamily(ctx, rec.FamilyID); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\ts.audit.TokenReuse(rec.UserID, rec.FamilyID)\n\t\treturn \"\", ErrTokenReuse\n\t}\n\tif time.Now().After(rec.ExpiresAt) {\n\t\treturn \"\", ErrExpired\n\t}\n\treturn s.issue(ctx, rec.UserID, rec.FamilyID) \u002F\u002F marks rec used, same family\n}",[1531,1534,1537,1540,1543,1548,1551],{"lines":1532},[1533],"A refresh token used twice means one of the two holders is not your user.",{"lines":1535},[1536],"Rotation alone just hands out a new token. Rotation plus a record of the old one hands you a signal, and the signal is the point.",{"lines":1538},[1539],"Every POST \u002Fauth\u002Frefresh issues a new refresh token and marks the presented one used. When a token that is already used comes back, do not simply reject that one request.",{"lines":1541},[1542],"Revoke the whole family — every token descended from that login — and log both holders out.",{"lines":1544},[1545,1546,1547],"— family_id is the device or session. Set at login, carried through every rotation.","— The legitimate user re-authenticates. So does the attacker, and they cannot.","— Rejecting only the replayed request leaves the thief holding a valid chain.",{"lines":1549},[1550],"Without reuse detection a stolen refresh token is silent. The thief rotates, your user rotates, and whoever loses the race gets a 401 they read as a flaky session and a re-login they forget by lunchtime. With it, that 401 is a security event carrying a user_id and a family_id.",{"lines":1552,"question":268},[1553],"When a refresh fails in your system, does anything anywhere find out?",{"id":1555,"track":312,"slug":1556,"status":369,"code":1557,"body":1560},"E4","never-store-the-token",{"caption":1558,"source":1559},"The refresh token record — no plaintext anywhere in it","CREATE TABLE refresh_tokens (\n    selector     TEXT        PRIMARY KEY,           -- id, sent to the client\n    verifier_hash BYTEA      NOT NULL,              -- HMAC\u002FSHA256, compared constant-time\n    user_id      UUID        NOT NULL REFERENCES users (id),\n    family_id    UUID        NOT NULL,              -- the device\u002Fsession\n    issued_at    TIMESTAMPTZ NOT NULL DEFAULT now(),\n    expires_at   TIMESTAMPTZ NOT NULL,\n    used_at      TIMESTAMPTZ,                       -- set on rotation\n    revoked_at   TIMESTAMPTZ                        -- set on logout or reuse\n);\n\nCREATE INDEX ON refresh_tokens (family_id);\nCREATE INDEX ON refresh_tokens (user_id) WHERE revoked_at IS NULL;",[1561,1564,1567,1570,1573,1578,1581],{"lines":1562},[1563],"Store the hash. Selector, verifier, family — the same shape as a password table.",{"lines":1565},[1566],"A refresh token table in plaintext is a credential store. Read it once and you hold every live session on the platform.",{"lines":1568},[1569],"The value you hand the browser is two parts: a selector you look up by, and a verifier you compare. Store the selector as an id and the verifier as an HMAC or SHA-256 hash.",{"lines":1571},[1572],"Around them: user_id, expires_at, revoked_at, family_id.",{"lines":1574},[1575,1576,1577],"— Lookup is by selector, so it is an index hit rather than a scan over hashes.","— Comparison is constant-time against the stored digest.","— Never store the plaintext token. Not in a debug log either.",{"lines":1579},[1580],"The storage engine is a real trade rather than a preference. SQL buys auditability and a device list the user can see and revoke. Redis buys speed and TTL eviction for free, and costs you every session on the day you lose the instance — fine if logging everyone out is acceptable, not fine if you shipped that device list.",{"lines":1582,"question":268},[1583],"Could you build a device list for one user out of what you store today, without adding a column?",{"id":1585,"track":312,"slug":1586,"status":369,"code":1587,"body":1590},"E5","csrf-is-about-cookies",{"caption":1588,"source":1589},"Two requests, two auth mechanisms, two different threat models","GET \u002Fapi\u002Forders HTTP\u002F1.1\nHost: api.example.com\nAuthorization: Bearer eyJhbGciOiJFUzI1NiIs...\n# no cookie involved -> the browser will never send this header\n# for a cross-site request -> no CSRF exposure, no CSRF token needed\n\nPOST \u002Fauth\u002Frefresh HTTP\u002F1.1\nHost: api.example.com\nOrigin: https:\u002F\u002Fapp.example.com\nCookie: refresh=8f2a...             # attached by the browser, not by the SPA\n# cookie carries identity -> CSRF is in scope for this route\n# baseline: SameSite=Lax + POST-only + same-site deployment\n\nHTTP\u002F1.1 200 OK\nSet-Cookie: refresh=91be...; HttpOnly; Secure; SameSite=Lax; Path=\u002Fauth\u002Frefresh\nContent-Type: application\u002Fjson\n\n{\"access_token\":\"eyJhbGciOiJFUzI1NiIs...\"}",[1591,1594,1597,1600,1603,1608,1611],{"lines":1592},[1593],"Cookies carry identity, so CSRF matters. Headers carry identity, so it does not.",{"lines":1595},[1596],"The browser attaches cookies to cross-site requests on its own. It never attaches an Authorization header for anyone.",{"lines":1598},[1599],"So map your endpoints by how they authenticate, not by what they do. Everything under \u002Fapi takes a Bearer header: no CSRF exposure, nothing to add.",{"lines":1601},[1602],"The cookie-authenticated endpoints in this design are exactly two — POST \u002Fauth\u002Frefresh and POST \u002Fauth\u002Flogout.",{"lines":1604},[1605,1606,1607],"— Refresh cookie is SameSite=Lax.","— Refresh and logout are POST-only, with no state-changing GETs.","— SPA and API are same-site, under one registrable domain.",{"lines":1609},[1610],"With those three together, practical CSRF risk on those two endpoints is already mitigated in modern browsers, and CSRF tokens are not strictly required. Both halves of that sentence carry conditions. Move the API to another site and you are on SameSite=None, and the baseline is gone. Accept cookie auth on one normal endpoint and the CSRF surface goes from two routes to all of them.",{"lines":1612,"question":268},[1613],"Which of your endpoints would still authenticate a request that arrived with no `Authorization` header at all?",{"id":1615,"track":312,"slug":1616,"status":369,"body":1617,"imageSpec":1641},"E6","site-is-not-domain",[1618,1621,1624,1627,1630,1635,1638],{"lines":1619},[1620],"app.example.com and api.example.com are different origins and the same site, and both facts change your config.",{"lines":1622},[1623],"Origin is scheme, host and port. Site is scheme plus eTLD+1. Two words, two mechanisms.",{"lines":1625},[1626],"SameSite is evaluated on site. CORS is evaluated on origin. That one sentence settles most of the confusion in this area.",{"lines":1628},[1629],"A SameSite=Lax refresh cookie IS sent between subdomains of example.com, and is NOT sent from evil.com. Meanwhile api.example.com still needs an explicit CORS allowlist for https:\u002F\u002Fapp.example.com, because a different subdomain is a different origin.",{"lines":1631},[1632,1633,1634],"— SameSite answers: will the browser send the cookie.","— CORS answers: can JavaScript read the response.","— Neither answers the other, and neither substitutes for the other.",{"lines":1636},[1637],"The common failure: a team switches to SameSite=None because refresh is failing between their own subdomains. SameSite was never involved — the browser had been sending the cookie the whole time, and the preflight was failing on credentials. A CSRF baseline gets traded away to fix a CORS bug, and refresh starts working, which is the worst possible outcome.",{"lines":1639,"question":268},[1640],"Are your SPA and your API under one registrable domain — and if they are not, was that a decision or an accident of hosting?","Two stacked bands. Top band ‘ORIGIN — scheme + host + port’ shows https:\u002F\u002Fapp.example.com and https:\u002F\u002Fapi.example.com as two separate boxes with a red ‘different’ between them, labelled ‘CORS applies: api must allowlist the app origin’. Bottom band ‘SITE — scheme + registrable domain (eTLD+1)’ shows both hostnames collapsing with arrows into one box https:\u002F\u002Fexample.com, marked ‘same’, labelled ‘SameSite=Lax cookie IS sent’. Off to the right of the bottom band, a detached box evil.com with a struck-through arrow toward api.example.com labelled ‘cross-site: Lax cookie NOT sent’. Footer strip: ‘SameSite = will the browser send the cookie? \u002F CORS = can JS read the response?’",{"id":1643,"track":312,"slug":1644,"status":369,"code":1645,"body":1648},"E7","double-submit-cookie",{"caption":1646,"source":1647},"Bootstrap, then the protected request — the value travels twice","GET \u002Fauth\u002Fcsrf HTTP\u002F1.1\nHost: api.example.com\n\nHTTP\u002F1.1 200 OK\nSet-Cookie: csrf=Kq7dF1s9...; HttpOnly; Secure; SameSite=None; Path=\u002F\nContent-Type: application\u002Fjson\n\n{\"csrf_token\":\"Kq7dF1s9...\"}\n# the SPA keeps the token from the body: cross-site, it cannot read the cookie\n\nPOST \u002Fauth\u002Frefresh HTTP\u002F1.1\nHost: api.example.com\nOrigin: https:\u002F\u002Fapp.example.com      # checked against a strict allowlist\nX-CSRF-Token: Kq7dF1s9...            # set by the SPA, from the body above\nCookie: refresh=8f2a...; csrf=Kq7dF1s9...\n# backend: both present, and equal -> proceed. Nothing stored server-side.\n\nHTTP\u002F1.1 403 Forbidden\n# what evil.com gets: the browser attaches both cookies, but the page\n# never saw the token, so the header is missing or wrong",[1649,1652,1655,1658,1661,1666,1669],{"lines":1650},[1651],"When SameSite is not available to you, here is the one CSRF defence that stores nothing.",{"lines":1653},[1654],"SameSite=None means the baseline is gone. You need a value evil.com cannot produce, and no table to keep it in.",{"lines":1656},[1657],"GET \u002Fauth\u002Fcsrf sets a random csrf cookie and returns the same value in the response body. The SPA keeps it from the body — from another site it could never read the API’s cookies — so the cookie can stay HttpOnly.",{"lines":1659},[1660],"Every protected request then carries that value twice — once in the cookie the browser attaches on its own, once in an X-CSRF-Token header the SPA sets by hand. The backend requires both to exist and to match, and stores nothing.",{"lines":1662},[1663,1664,1665],"— A third-party site can make the browser send the cookie. It cannot read the token to set the header.","— Reach for this on SameSite=None, on cookie auth for normal endpoints, or for defence in depth.","— Enforce a strict Origin allowlist alongside it, not instead of it.",{"lines":1667},[1668],"The whole defence rests on the attacker being unable to know your token, so the failure modes are anything that changes that. Any subdomain can plant a csrf cookie on example.com, including the one running a marketing vendor's script — which is why OWASP recommends signing the value rather than trusting a match alone. A CORS config that reflects arbitrary origins with credentials hands over the token. Cheap is not the same as assumption-free.",{"lines":1670,"question":268},[1671],"If you are on `SameSite=None` today, what is stopping a cross-site POST to your refresh endpoint right now?",{"id":1673,"track":312,"slug":1674,"status":369,"body":1675,"imageSpec":1699},"E8","oauth-tokens-stay-server-side",[1676,1679,1682,1685,1688,1693,1696],{"lines":1677},[1678],"Your frontend should never see a Google token.",{"lines":1680},[1681],"The code in the redirect is not a credential yet. Exchange it in the browser and the provider’s tokens end up somewhere you cannot revoke them.",{"lines":1683},[1684],"The browser carries one thing, and it carries it to your backend: GET \u002Fauth\u002Fgoogle\u002Fcallback?code=….",{"lines":1686},[1687],"The backend exchanges that code server-to-server, verifies the ID token, finds or creates the local user, and stores Google’s refresh token server-side only.",{"lines":1689},[1690,1691,1692],"— Then it issues your own access and refresh tokens.","— From that point the flow is identical to local login.","— Rotation, reuse detection and logout are the same code path, not a second one.",{"lines":1694},[1695],"This is why OAuth is a login and not an auth system. If the provider’s tokens reach the SPA, your session lifetime is Google’s to decide, your revocation story is a support ticket, and the second provider you add means a second set of rules at every point you check a request. Issue your own tokens and there is exactly one session model in the codebase.",{"lines":1697,"question":268},[1698],"When a user disappears from your Google Workspace, what in your system notices, and how long does it take?","A four-lane sequence diagram: Browser, Your API, Google, Your token store. Browser -> Google: redirect with response_type=code. Google -> Your API: GET \u002Fauth\u002Fgoogle\u002Fcallback?code=… Your API -> Google: exchange code for tokens (server-to-server), labelled ‘this leg never touches the browser’. Your API: verify ID token, find-or-create local user. Your API -> Your token store: persist Google refresh token, and a new refresh token family. Your API -> Browser: your own access token in the body, your own refresh token as an HttpOnly cookie. A shaded band across the Browser lane is labelled ‘no Google token ever crosses this line’, and the last arrow is annotated ‘from here the flow is normal login’.",{"id":1701,"track":323,"slug":1702,"status":369,"code":1703,"body":1706},"G1","no-two-phase-commit",{"caption":1704,"source":1705},"A saga is a list of steps and their inverses","type Step struct {\n\tDo   func(context.Context) error\n\tUndo func(context.Context) error \u002F\u002F nil from the pivot onward\n}\n\nfunc Run(ctx context.Context, steps []Step) error {\n\tpivoted := false\n\tfor i, s := range steps {\n\t\tif err := s.Do(ctx); err != nil {\n\t\t\tif pivoted {\n\t\t\t\treturn err \u002F\u002F past the pivot: retry forward, never roll back\n\t\t\t}\n\t\t\treturn errors.Join(err, compensate(ctx, steps[:i]))\n\t\t}\n\t\tpivoted = pivoted || s.Undo == nil\n\t}\n\treturn nil\n}\n\nfunc compensate(ctx context.Context, done []Step) error {\n\tvar errs []error\n\tfor i := len(done) - 1; i >= 0; i-- {\n\t\terrs = append(errs, done[i].Undo(ctx)) \u002F\u002F a failed undo is kept, not dropped\n\t}\n\treturn errors.Join(errs...)\n}",[1707,1710,1713,1716,1719,1724,1727,1730],{"lines":1708},[1709],"You have two services and two databases. The transaction you want does not exist.",{"lines":1711},[1712],"2PC is the textbook answer. Nobody runs it: a dead coordinator leaves both databases holding locks.",{"lines":1714},[1715],"What you get instead is a saga — a sequence of local transactions, each committing on its own, each carrying an action that semantically undoes it.",{"lines":1717},[1718],"The trade is explicit, and worth saying out loud:",{"lines":1720},[1721,1722,1723],"— You give up atomicity. There is a window where the order exists and the stock does not.","— You get eventual consistency, which means the window closes, not that it was never open.","— You write the rollback yourself. There is no ROLLBACK for a charge that already cleared.",{"lines":1725},[1726],"On the grocery order flow I worked on, the first version had no compensations at all — a failure past step two just logged and returned. Stock stayed reserved for baskets nobody ever paid for, and the fix was a nightly script. A saga is that script, moved into the transaction and made explicit.",{"lines":1728},[1729],"Next: the two kinds of saga step, and the one you cannot undo.",{"lines":1731,"question":268},[1732],"When a step fails halfway through your flow today — is there code that undoes the first half, or a cron job?",{"id":1734,"track":323,"slug":1735,"status":369,"code":1736,"body":1739},"G2","compensable-vs-pivot",{"caption":1737,"source":1738},"Undo is nil from the pivot onward — that is the whole marker","func orderSteps(a *Ops) []Step {\n\treturn []Step{\n\t\t\u002F\u002F compensable: inverse semantic actions\n\t\t{Do: a.ReserveStock, Undo: a.UnreserveStock},\n\t\t{Do: a.ReserveCredit, Undo: a.ReleaseCredit},\n\n\t\t\u002F\u002F pivot: the charge settles, nothing after this rolls back\n\t\t{Do: a.CaptureCharge},\n\n\t\t\u002F\u002F post-pivot: forward-only, retried until it succeeds\n\t\t{Do: a.IssueInvoice},\n\t\t{Do: a.DispatchShipment},\n\t}\n}",[1740,1743,1746,1749,1752,1755,1758,1761],{"lines":1741},[1742],"Every step in a saga is one of two kinds, and confusing them is how you refund money twice.",{"lines":1744},[1745],"Compensable steps can be undone. The pivot cannot — past it, rollback is not an option.",{"lines":1747},[1748],"A compensable step has an inverse semantic action. Reserve becomes unreserve. Allocate becomes release. It is not a delete and it is not a restore-from-backup; it is a second business operation that leaves the world where it started.",{"lines":1750},[1751],"The pivot is the first step that cannot be taken back. A settled card charge. An email to the customer. A parcel handed to a courier.",{"lines":1753},[1754],"So the rule splits at that line. Fail before the pivot and you roll backward, undoing in reverse order. Fail after it and you have nowhere to go but forward: retry until the step succeeds, and page someone if it never does.",{"lines":1756},[1757],"Write a compensation for a post-pivot step because the pattern says every step gets one, and eventually the retry succeeds and the compensation fires on the same delivery. The customer is charged once and refunded twice. It is one line of ledger drift per order, and nothing alerts on it.",{"lines":1759},[1760],"Next: how to hold a lock for five minutes without holding a lock.",{"lines":1762,"question":268},[1763],"Where is the pivot in the flow you own — and can everyone on the team point at the same step?",{"id":1765,"track":323,"slug":1766,"status":369,"code":1767,"body":1770},"G3","semantic-locking",{"caption":1768,"source":1769},"AcquireLock — the expiry clause is the design","CREATE TYPE item_status AS ENUM ('AVAILABLE', 'LOCKED_FOR_SAGA', 'SOLD');\n\nCREATE INDEX idx_inv_saga_locks ON inventory_items (status, lock_expires_at)\nWHERE status = 'LOCKED_FOR_SAGA';\n\nUPDATE inventory_items\nSET    status          = 'LOCKED_FOR_SAGA',\n       saga_id         = $1,\n       lock_expires_at = NOW() + $2::interval,\n       version         = version + 1,\n       updated_at      = NOW()\nWHERE  id = $3\n  AND (status = 'AVAILABLE'\n       OR (status = 'LOCKED_FOR_SAGA' AND lock_expires_at \u003C NOW()));\n-- 0 rows affected => somebody else holds a live lease",[1771,1774,1777,1780,1783,1786,1789,1792,1795],{"lines":1772},[1773],"You cannot hold a database lock for the length of a business transaction. So do not — lock in the application.",{"lines":1775},[1776],"A saga step waits on brokers, processors, people. No pool survives a lock held that long.",{"lines":1778},[1779],"A semantic lock is a status column with an owner and an expiry: LOCKED_FOR_SAGA, a saga_id, and a lock_expires_at. Readers see the state and skip the row. Nothing blocks.",{"lines":1781},[1782],"The clause that matters is the second half of the WHERE. A row is lockable when it is AVAILABLE, or when it is already locked and the lease has run out.",{"lines":1784},[1785],"That OR is what makes a dead worker harmless. A crashed saga does not release anything — it stops renewing, and the row frees itself at the TTL. No sweeper, no reaper process, no operator.",{"lines":1787},[1788],"Two details that are not decoration. The enum keeps a typo out of the status column at write time. The partial index covers only locked rows, so the lease scan never touches the ninety-odd percent of the table that is available.",{"lines":1790},[1791],"The version I saw fail had the status column and no expiry. A worker was OOM-killed mid-saga and its reserved items stayed locked until someone ran an UPDATE by hand — two hours of a catalogue that would not sell, on a Saturday.",{"lines":1793},[1794],"Next: the three IDs, and what breaks when you ship one.",{"lines":1796,"question":268},[1797],"What is your lease TTL, and did you pick it from the p99 of the saga or from a round number?",{"id":1799,"track":323,"slug":1800,"status":369,"code":1801,"body":1804},"G4","three-ids",{"caption":1802,"source":1803},"Three fields, three lifetimes","type Envelope struct {\n\tSagaID      uuid.UUID `json:\"sagaId\"`      \u002F\u002F the business transaction; durable, survives restarts\n\tCausationID uuid.UUID `json:\"causationId\"` \u002F\u002F the message that produced this one\n\tMessageID   uuid.UUID `json:\"messageId\"`   \u002F\u002F this delivery's own identity\n\tTraceID     string    `json:\"traceId\"`     \u002F\u002F infrastructure; dies with the request\n\tTimestamp   time.Time `json:\"timestamp\"`   \u002F\u002F RFC 3339, always UTC\n}\n\nfunc (e Envelope) DedupeKey(step string) string {\n\treturn e.SagaID.String() + \":\" + step \u002F\u002F never TraceID\n}",[1805,1808,1811,1814,1819,1822,1825,1828],{"lines":1806},[1807],"TraceId, SagaId, CausationId. Three IDs, three jobs, and teams routinely ship one.",{"lines":1809},[1810],"The one they ship is TraceId, because the tracing library handed it over for free.",{"lines":1812},[1813],"They are not interchangeable:",{"lines":1815},[1816,1817,1818],"— TraceId is infrastructure. It exists to stitch spans together, and it belongs to a request, not to a business fact.","— SagaId is the durable identity of the business transaction. It is the correlation key on the consuming side and the lock owner in the database.","— CausationId is the id of the message that caused this one. It is how you rebuild the tree after a fan-out, when ten messages share a saga.",{"lines":1820},[1821],"SagaId outlives the request that created it. A saga that starts on Monday and compensates on Wednesday has one SagaId and dozens of trace ids, and if your dedupe key is the trace id you do not have a dedupe key.",{"lines":1823},[1824],"That is the failure I have actually hit. A redelivery arrived with a fresh trace id, the idempotency check saw a value it had never stored, and a compensation that had already run ran again. The bug reads as a duplicate refund; the cause is one identifier doing two jobs.",{"lines":1826},[1827],"Next: why every message in the flow arrives twice.",{"lines":1829,"question":268},[1830],"Open a message on your broker right now. How many of the three are in the headers?",{"id":1832,"track":323,"slug":1833,"status":369,"code":1834,"body":1837},"G5","idempotency-everywhere",{"caption":1835,"source":1836},"The marker and the work commit together","func (s *Store) Unreserve(ctx context.Context, sagaID uuid.UUID, itemID int64) error {\n\ttx, err := s.db.BeginTx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\tres, err := tx.ExecContext(ctx,\n\t\t`INSERT INTO saga_steps (saga_id, step) VALUES ($1, 'unreserve')\n\t\t ON CONFLICT (saga_id, step) DO NOTHING`, sagaID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif n, _ := res.RowsAffected(); n == 0 {\n\t\treturn nil \u002F\u002F already compensated; the second delivery is a no-op\n\t}\n\tif err := releaseLock(ctx, tx, itemID, sagaID); err != nil {\n\t\treturn err\n\t}\n\treturn tx.Commit()\n}",[1838,1841,1844,1847,1850,1853,1856,1859],{"lines":1839},[1840],"Every message in a saga will be delivered twice. Plan for the second one.",{"lines":1842},[1843],"At-least-once is the only guarantee the broker gives you. A redelivery is not an edge case, it is ordinary traffic.",{"lines":1845},[1846],"A consumer acknowledges after it commits. If it dies in the gap — or the ack is lost, or the lease expires while a GC pause runs long — the broker does the only correct thing available to it and sends the message again.",{"lines":1848},[1849],"So every consumer and every compensating step deduplicates on the SagaId, plus the step name. Write the marker in the same transaction as the work, or the marker and the work can disagree.",{"lines":1851},[1852],"Compensations need this more than the forward path does, and they get it less. A forward step that runs twice usually fails a uniqueness constraint and makes noise. A compensation that runs twice releases stock that a newer saga has since reserved, or refunds an amount that was only ever charged once — and it does it quietly, because undoing is supposed to be the safe direction.",{"lines":1854},[1855],"The one I remember cost a warehouse a day of reconciliation: a retried unreserve freed items the next order had already locked, and the count on the shelf and the count in the database diverged with nothing in the log to say why.",{"lines":1857},[1858],"Next: two saga engines, one event on the boundary.",{"lines":1860,"question":268},[1861],"Is your dedupe marker written in the same transaction as the work it guards, or right after it?",{"id":1863,"track":323,"slug":1864,"status":369,"body":1865,"imageSpec":1893},"G6","orchestration-vs-choreography",[1866,1869,1872,1875,1878,1881,1884,1887,1890],{"lines":1867},[1868],"Temporal on one side, a MassTransit state machine on the other, an event on the boundary.",{"lines":1870},[1871],"Which one is correct is the wrong argument. Both are, in different bounded contexts.",{"lines":1873},[1874],"Orchestration means one component knows the whole sequence. It calls step two after step one, it holds the compensation stack, and when it fails you can read the history and see exactly where. The cost is that the sequence lives in one place, and that place owns everybody.",{"lines":1876},[1877],"Choreography means services react to events. Nothing knows the whole flow, which is what makes it cheap to add a service and expensive to answer “why did this order stop”.",{"lines":1879},[1880],"The shape that survived contact with production was neither, and both. The Go domain — order, inventory, shipping — is orchestrated by a Temporal workflow, because that sequence has compensations and needed a stack. Billing and invoicing, owned by a different team on .NET, is a MassTransit state machine correlated by SagaId.",{"lines":1882},[1883],"Between them: one event on RabbitMQ. FulfillmentCompleted goes out, the .NET side transitions, and on failure it publishes a signal the workflow is waiting on. Neither engine knows the other exists — the workflow does not know a state machine consumed it, and the state machine does not know a workflow produced it.",{"lines":1885},[1886],"That is what makes the split affordable. A team picks its own engine, and the contract is a message, not a runtime.",{"lines":1888},[1889],"Next: what breaks when Go's JSON meets MassTransit's envelope.",{"lines":1891,"question":268},[1892],"Where is the seam in your system — a shared library, a shared database, or a message?","A two-column diagram. Left column, labelled Go: a Temporal workflow box with three ordered activities beneath it — ReserveStock, ReserveShipping, Publish — and a dashed return arrow labelled compensate. Right column, labelled .NET: a MassTransit state machine with states Initial, FulfillmentDone, Paid. Between the columns, a single broker lane carrying one arrow left-to-right labelled FulfillmentCompleted and one arrow right-to-left labelled PaymentFailed. Both arrows annotated SagaId to show it is the only shared identity.",{"id":1895,"track":323,"slug":1896,"status":369,"code":1897,"body":1900},"G7","crossing-engines",{"caption":1898,"source":1899},"Both sides of one boundary","\u002F\u002F Go — plain JSON, UTC timestamp, SagaId as the correlation key\ntype FulfillmentCompletedEvent struct {\n\tSagaID      uuid.UUID `json:\"sagaId\"`\n\tOrderID     string    `json:\"orderId\"`\n\tAmountMinor int64     `json:\"amountMinor\"` \u002F\u002F cents: never a float\n\tTimestamp   time.Time `json:\"timestamp\"`   \u002F\u002F time.Now().UTC() -> RFC 3339\n}\n\n\u002F\u002F .NET — accept it without the MassTransit envelope\n\u002F\u002F cfg.UseRawJsonDeserializer(RawJsonDeserializerOptions.All);\n\u002F\u002F\n\u002F\u002F Event(() => FulfillmentCompleted,\n\u002F\u002F       x => x.CorrelateById(m => m.Message.SagaId));",[1901,1904,1907,1910,1913,1916,1920,1923,1926],{"lines":1902},[1903],"Go publishes JSON. .NET expects an envelope. Here is where the two disagree.",{"lines":1905},[1906],"MassTransit wraps messages in an envelope of its own. A plain Go payload does not match it, so the consumer never fires.",{"lines":1908},[1909],"There is no error to read. The message is delivered, deserialization finds no messageType, and it goes to the skipped queue while the Go side logs a clean publish and moves on. Both halves look healthy and the order sits still.",{"lines":1911},[1912],"The fix is one line on the .NET side: cfg.UseRawJsonDeserializer(RawJsonDeserializerOptions.All). Then the Go struct is the contract, and the field names in the JSON tags are the only thing binding the two teams.",{"lines":1914},[1915],"Two more that bite in the same seam:",{"lines":1917},[1918,1919],"— Timestamps. Emit RFC 3339 UTC from time.Now().UTC(). A local-time or truncated value deserializes into DateTime without complaint and lands hours off, or loses sub-second precision your ordering depends on.","— Timeouts are asymmetric. The Go workflow's wait must exceed MassTransit's cumulative retry plus backoff. Five retries with exponential backoff is minutes, not seconds; a 30-second workflow timer will compensate a transaction the other side is still successfully retrying.",{"lines":1921},[1922],"That last one is the strangest failure this boundary produces: a rollback and a success for the same order, both correct according to the system that emitted them.",{"lines":1924},[1925],"Next: why you cannot edit a running workflow.",{"lines":1927,"question":268},[1928],"Have you added up your consumer's total retry window, or is the producer's timeout still a round number?",{"id":1930,"track":323,"slug":1931,"status":369,"code":1932,"body":1935},"G8","workflow-determinism",{"caption":1933,"source":1934},"The version gate, and a context that survives cancellation","v := workflow.GetVersion(ctx, \"add-fraud-check\", workflow.DefaultVersion, 1)\nif v == 1 {\n\t\u002F\u002F executions started before this deploy return DefaultVersion and skip it\n\tif err := workflow.ExecuteActivity(ctx, a.FraudCheck, orderID).Get(ctx, nil); err != nil {\n\t\treturn err\n\t}\n}\n\nif err := workflow.ExecuteActivity(ctx, a.CaptureCharge, orderID).Get(ctx, nil); err != nil {\n\t\u002F\u002F ctx may already be cancelled here; compensation needs its own\n\tcompCtx, cancel := workflow.NewDisconnectedContext(ctx)\n\tdefer cancel()\n\t_ = workflow.ExecuteActivity(compCtx, a.ReleaseLock, itemID, sagaID).Get(compCtx, nil)\n\treturn err\n}",[1936,1939,1942,1945,1948,1951,1954,1957],{"lines":1937},[1938],"You cannot edit a running Temporal workflow. workflow.GetVersion is not optional.",{"lines":1940},[1941],"Temporal rebuilds state by replaying history. Change the call order and an in-flight run has nowhere to land.",{"lines":1943},[1944],"Workflow code is not a script that runs once. It is a function replayed against a recorded history every time a worker picks the execution up, and the replay must produce the same sequence of commands it produced the first time. Insert an activity before an existing one and the fifth command no longer matches the fifth event. The execution does not fail loudly — it wedges on a non-determinism error and stops making progress.",{"lines":1946},[1947],"The gate is workflow.GetVersion. It records the branch it took into history the first time it runs, so old executions keep returning DefaultVersion and take the old path, and new ones take the new one. The if is not for you. It is for the run that started before you deployed.",{"lines":1949},[1950],"The second half of that snippet is a separate lesson. Compensation runs on a disconnected context — when the workflow is cancelled or times out, the derived context is already dead, and the rollback you carefully wrote never gets to execute. Every compensation can be correct and none of them run.",{"lines":1952},[1953],"Sagas are long. A three-day order flow means production is running four versions of your workflow at once, whether you planned for it or not.",{"lines":1955},[1956],"Next: the rollback failed, and the last resort is a person.",{"lines":1958,"question":268},[1959],"How long does your longest-running workflow live — and how many deploys is that?",{"id":1961,"track":323,"slug":1962,"status":369,"code":1963,"body":1966},"G9","when-compensation-fails",{"caption":1964,"source":1965},"The last hop of a failed compensation","ao := workflow.ActivityOptions{\n\tStartToCloseTimeout: 30 * time.Second,\n\tRetryPolicy: &temporal.RetryPolicy{\n\t\tInitialInterval:    time.Second,\n\t\tBackoffCoefficient: 2.0,\n\t\tMaximumAttempts:    6, \u002F\u002F finite: retrying forever hides the failure\n\t},\n}\ncompCtx, cancel := workflow.NewDisconnectedContext(workflow.WithActivityOptions(ctx, ao))\ndefer cancel()\n\nif err := workflow.ExecuteActivity(compCtx, a.ReleaseLock, itemID, sagaID).Get(compCtx, nil); err != nil {\n\t\u002F\u002F do not swallow it: park it where an operator will find it\n\t_ = workflow.ExecuteActivity(compCtx, a.DeadLetter, sagaID, \"release_lock\", err.Error()).\n\t\tGet(compCtx, nil)\n\treturn err\n}",[1967,1970,1973,1976,1979,1982,1985,1988,1991],{"lines":1968},[1969],"The rollback failed. Now what?",{"lines":1971},[1972],"The compensation retried, backed off, retried, and exhausted its policy. The row is still LOCKED_FOR_SAGA and the money is somewhere.",{"lines":1974},[1975],"Every article about sagas ends at “and then the compensation runs”. Production does not end there. The compensating call hits a downstream service that is down, or a payment provider that returns a permanent decline on a refund, or a row that a later saga has already moved on from.",{"lines":1977},[1978],"The rule is that a compensation which exhausts its retries stops retrying and becomes visible. It routes to a dead-letter queue — MassTransit puts them in _error queues — carrying the SagaId, the step, and the original message, and it raises an alert with a human on the other end.",{"lines":1980},[1981],"What it must not do is retry forever quietly, and it must not swallow the failure and let the workflow complete. A saga that reports success with an un-compensated step is worse than one that reports failure, because nothing downstream will ever look at it again.",{"lines":1983},[1984],"The uncomfortable part: the last resort of a distributed transaction is a person with database access and a runbook. That is not a design flaw to engineer away. It is the honest ending of giving up atomicity in the first place, and the work is making that person's job possible — one SagaId that opens every log, every message and every locked row across both stacks.",{"lines":1986},[1987],"If the DLQ is empty and nobody is alerted on it, you do not have a saga. You have an optimistic sequence of calls.",{"lines":1989},[1990],"That closes the track. No 2PC; compensable steps and the pivot; a lease instead of a lock; three IDs; idempotent everything; Temporal and MassTransit either side of one event; a version gate; and a human at the end. Nine posts, one order flow.",{"lines":1992,"question":268},[1993],"Who gets paged when a compensation dead-letters in your system — and is there a runbook, or a Slack thread?",1791905798224]