{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/repo-txn-uow/",
"description": "Decoupling business logic from storage in Go, adding transaction support without leaking SQL details, and coordinating atomic writes across multiple repositories using a unit of work.",
"path": "/go/repo-txn-uow/",
"publishedAt": "2026-03-21T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Database",
"Design Patterns"
],
"textContent": "This post started as a pair of quick answers to questions on [r/golang]. The first was about\nwhether [a repository layer on top of sqlc is worth it]. The second was about how to [handle\ntransactions when the interface hides storage details]. Both turned into short shards on\nthis site. This post ties them together and covers what to do when transactions need to span\nmultiple repositories.\n\nIt walks through three stages, each building on the last:\n\n1. Put a repository interface between your service logic and your storage layer\n2. Add transaction support to a single repository without leaking SQL into the service\n3. Coordinate transactions across multiple repositories using a unit of work\n\nAll code examples use SQLite. Working examples for the [single-store version] and the\n[cross-store version] are on GitHub.\n\nWhat's a repository?\n\nMartin Fowler defined the repository pattern in [Patterns of Enterprise Application\nArchitecture]:\n\n> A Repository mediates between the domain and data mapping layers using a collection-like\n> interface for accessing domain objects.\n\nIn Go, a repository is just an interface. The service depends on the interface, a concrete\npackage implements it, and they live in separate packages. The service defines what it\nneeds, the storage satisfies it. The [dependency inversion principle] in action.\n\nTo see why this matters, consider what happens when you skip it.\n\nWhat happens without one\n\nSay you're building a bookstore service with [sqlc]. The generated code gives you a\nQueries struct with methods like GetBook and CreateBook. The tempting thing is to\ninject that directly into your service:\n\nThis compiles and runs, but the service is now welded to sqlc's generated types. Every\nservice method imports the db package. If you want to test RegisterBook without a\ndatabase, you need to mock the entire Queries struct or spin up a test database. If you\nlater switch from sqlc to raw SQL, or from Postgres to DynamoDB, you're rewriting the\nservice layer too.\n\nThe service should describe _what_ it needs from storage without knowing _how_ storage does\nit. \"Get me a book by ID\" and \"create this book\" are the what. SQL queries, connection\npools, and table schemas are the how. A small interface fixes that.\n\nAdding a repository interface\n\nThe interface lives in the book package alongside the domain types. This is the business\nlogic package. It has no imports from database/sql or any storage library:\n\nTwo methods. Get retrieves a book by ID, Create persists a new one and returns the\ngenerated ID. The interface says nothing about SQL, tables, or connection pools. Any storage\nbackend that can get and create books can satisfy it.\n\nThe service depends only on Store:\n\nRegisterBook builds a Book, asks the store to persist it, and gets an ID back. It\ndoesn't import anything from database/sql. The book package has zero storage\ndependencies.\n\nNow we need something that actually talks to a database.\n\nSQLite implementation\n\nA separate sqlite package satisfies the Store interface. I'm writing the queries by hand\nhere to avoid the sqlc ceremony, but the structure would be the same. sqlc would just\ngenerate the query methods for you.\n\nBefore writing the store methods, there's one thing to set up. sqlc generates a DBTX\ninterface that both sql.DB and sql.Tx satisfy. sql.DB is a connection pool,\nsql.Tx is a transaction:\n\nWhy does this matter? Because sql.DB has these two methods, and so does sql.Tx. Any\ncode written against DBTX works with either one. We don't need this for the basic\nrepository, but it becomes important when we add transactions later.\n\nThe store struct holds DBTX instead of sql.DB. If the store held sql.DB directly, we\ncouldn't later construct a store backed by a transaction. Holding DBTX keeps that door\nopen:\n\nThe query methods call s.db.ExecContext and s.db.QueryRowContext, which right now go\nthrough a sql.DB connection pool:\n\nLater, when we add transactions, s.db will be a sql.Tx instead of a sql.DB, and\nthese same methods will execute against the transaction without any code changes. That's the\npayoff of holding DBTX.\n\nWiring it up at startup is one line per dependency:\n\nThe service receives a Store, which is the interface. The SQLite package receives a\nsql.DB, which satisfies DBTX. Neither package imports the other.\n\nTesting without a database\n\nSince the service depends on an interface, we can test it without any database by writing an\nin-memory fake:\n\nThe var _ Store = (memStore)(nil) line is an [interface guard]. If memStore ever stops\nsatisfying Store, the build fails.\n\nThe test looks like production code, minus the database:\n\nThis runs in microseconds and exercises the same RegisterBook code that runs in\nproduction. If the storage layer changes from SQLite to Postgres tomorrow, this test stays\nthe same because it only depends on the interface.\n\nYou should still write integration tests against a real database (we'll see those shortly),\nbut the bulk of your service logic can be tested with fakes.\n\nSo far we have a clean separation: the service talks to an interface, the SQLite package\nimplements it, and tests use an in-memory fake. But every method on the interface runs\nindependently. If RegisterBook needs to make two writes that must succeed or fail\ntogether, we have a problem.\n\nAdding transactions to a single repository\n\nSay the business requirements change. When a book is registered, we now also need to write\nan audit log entry recording who created it and when. Both writes must be atomic: if the\nbook insert succeeds but the audit log fails, we don't want a book in the database with no\naudit trail. That means we need a transaction.\n\nThis is the question that xinoiP [raised on Reddit]:\n\n> How would you handle transactions with this approach? Since they are very specific to SQL.\n\nTo support the new requirement, the Store interface needs two additions. First, an\nAuditEntry type and a CreateAuditLog method for the audit writes. Second, a Tx method\nthat lets the service group multiple operations into a single transaction:\n\nCreateAuditLog is a regular data access method like Get and Create. The interesting\none is Tx. It takes a callback function that receives a Store. The Store passed to the\ncallback is backed by a database transaction, so every method called on it executes within\nthat transaction. Same idea as [passing locked state into a closure]. The caller doesn't\nmanage the lifecycle. No manual begin/commit/rollback, just like no manual lock/unlock. It\nworks with what the callback gives it.\n\nHere's how the SQLite implementation of Tx works:\n\nThe type assertion s.db.(sql.DB) checks that the underlying executor is a connection pool\nand not an existing transaction. You can't nest sql.Tx inside sql.Tx in database/sql.\nAfter starting the transaction with BeginTx, it builds a fresh BookStore whose db\nfield is the sql.Tx. This is the payoff of the DBTX setup from earlier. sql.Tx\nsatisfies DBTX, so the new store works with the exact same Get, Create, and\nCreateAuditLog methods. The callback gets this transactional store, and every query inside\nthe callback goes through the transaction. If the callback returns an error, we roll back.\nOtherwise we commit.\n\nThe caller never touches sql.Tx.\n\nUsing Tx in RegisterBook\n\nWith Tx on the interface, RegisterBook can now create a book and an audit log entry\natomically. It calls s.store.Tx, and everything inside the callback goes through the\ntransactional store:\n\nBoth tx.Create and tx.CreateAuditLog execute against the same sql.Tx. If either fails,\nthe callback returns an error, and Tx rolls back both writes. If both succeed, Tx\ncommits them together. RegisterBook never sees sql.Tx, sql.DB, or anything from\ndatabase/sql.\n\nTesting single-store transactions\n\nThe in-memory store needs a Tx method now. Since there's no real database, it just calls\nthe function directly with itself:\n\nThis is enough to test service logic: whether RegisterBook calls both Create and\nCreateAuditLog, and whether it handles errors correctly.\n\nFor integration tests that verify actual commit/rollback behavior at the database level, use\na real database:\n\nfailingStore embeds the real SQLite BookStore but overrides CreateAuditLog to always\nreturn an error. The sequence: Tx begins a transaction, Create inserts a book (inside\nthe transaction), CreateAuditLog fails, Tx rolls back, and the books table is empty.\n\nUnit tests with fakes cover service logic quickly. Integration tests with a real database\ncover transactional behavior. The interface makes both possible from the same service code.\n\nWhy not use context to pass the transaction?\n\nxinoiP's original suggestion was to put a sql.Tx in the context and have the store check\nfor it:\n\nThis works, but the service has to call something like ctx = WithTx(ctx, tx) before\ncalling the store, which means it knows a SQL transaction exists. That's the coupling the\ninterface was supposed to prevent.\n\nThere's another issue as well. Context values are untyped and invisible. If someone forgets\nto set the transaction in context, or sets it on the wrong context, the store silently falls\nback to the connection pool and the operations aren't atomic. With the callback approach,\nthe transactional store is passed as a function argument. It won't catch every mistake - you\ncould still accidentally call s.store instead of tx for one of several operations - but\nit's harder to miss than an invisible context value.\n\nWith the callback, the service says \"run these operations atomically\" and the store decides\nhow. Swap Postgres for DynamoDB tomorrow and the service code doesn't change.\n\nTransactions across multiple repositories\n\nThe per-store Tx from the previous sections works when all writes go through the same\nStore. Both Create and CreateAuditLog live on Store, so one store's Tx method can\nwrap them in a single transaction.\n\nBut domains grow. Say the bookstore now tracks inventory and handles orders. Books get a\nStock field, there's a new Order type, and a new Store interface for order-related\nqueries. Each store still has its own Tx:\n\nDecrementStock reduces a book's inventory count by one. A checkout flow needs to call\nDecrementStock on book.Store _and_ Create on order.Store, and both must commit or\nroll back together. If the stock decrements but the order insert fails, you've lost\ninventory with no corresponding order.\n\nYou might try nesting the callbacks:\n\nThis compiles, but books.Tx starts one sql.Tx for the book store and orders.Tx starts\na _second_, independent sql.Tx for the order store. If the order insert fails, the order\ntransaction rolls back, but the stock decrement has already committed in the first\ntransaction.\n\nEach store only knows how to build a transactional copy of itself. You need something that\ncan build _all_ stores from a single sql.Tx.\n\nUnit of work\n\nWe need a coordinator that starts a single database transaction and constructs every store\nfrom it. Martin Fowler called this pattern a Unit of Work in [Patterns of Enterprise\nApplication Architecture]:\n\n> A Unit of Work keeps track of everything you do during a business transaction that can\n> affect the database. When you're done, it figures out everything that needs to be done to\n> alter the database as a result of your work.\n\nFowler's original formulation tracks dirty objects in memory and flushes them all in one\ntransaction. ORMs like Hibernate implement it that way. In Go, we don't need object tracking\nsince our stores already know how to write to the database. We just need to start one\nsql.Tx, construct all stores from it, and pass them to a callback.\n\nSince the unit of work now owns transaction management, we can strip Tx from both store\ninterfaces. The stores go back to being pure data access:\n\nA Stores struct groups all the repositories together, and a UnitOfWork interface\nprovides the single RunInTx method that replaces per-store Tx:\n\nStores is a plain struct holding the same interfaces the service already depends on. As\nthe domain grows, you add more fields to it.\n\nThe SQLite implementation starts one transaction and constructs both stores from it:\n\nSame DBTX trick as before. NewBookStore(tx) and NewOrderStore(tx) both accept DBTX,\nand sql.Tx satisfies DBTX. Both stores execute against the same transaction. When the\ncallback returns, either everything commits or everything rolls back.\n\nUsing RunInTx in the service\n\nSince the service now uses a UnitOfWork for transactions instead of per-store Tx, its\ndependencies change. It takes a Stores for non-transactional reads and a UnitOfWork for\natomic writes:\n\nPlaceOrder reads the book outside the transaction (no need to hold a lock for a read),\nthen uses RunInTx for the two writes that must be atomic:\n\nInside the callback, tx.Books and tx.Orders both execute against the same sql.Tx. If\nDecrementStock succeeds but Orders.Create fails, the entire transaction rolls back and\nthe stock decrement is undone.\n\nSingle-store operations work the same way. RegisterBook goes through RunInTx and uses\nonly tx.Books, ignoring tx.Orders:\n\nOnce you have a unit of work, there's no need to keep per-store Tx. RunInTx handles both\nsingle-store and cross-store transactions.\n\nTesting cross-store transactions\n\nFor unit tests, the in-memory unit of work passes the stores straight through:\n\nFor integration tests, verify that a failure in one store actually rolls back writes from\nthe other. In this test, the order insert fails, and we check that the stock decrement was\nundone:\n\nfailingOrderUoW is a UnitOfWork whose order Store always fails on Create. It starts\na real sql.Tx, builds both stores from it with the failing order store swapped in, and\nrolls back when the callback returns an error:\n\nDecrementStock ran inside the transaction and modified the stock, but because the order\ninsert failed, the entire transaction rolled back and the stock is back to 5.\n\nIs this too much abstraction for Go?\n\nYes. Do I always do it? Nope.\n\nIn larger codebases though, it's easy to end up with a mess if you mix storage concerns into\nthe service logic. I've seen it play out many times: you start with spaghetti in the name of\nsimplicity and things get out of hand as the codebase grows. With LLMs, generating code is\ncheap. Guiding the clanker toward a good design doesn't cost much and pays dividends\nthroughout.\n\nThat said, I typically skip the ceremony when I'm knocking out something for my own use, or\nworking in a smaller codebase, or working in a codebase that doesn't do it already.\n\n\n\n\n[r/golang]:\n https://www.reddit.com/r/golang/\n\n[sqlc]:\n https://github.com/sqlc-dev/sqlc\n\n[gorm]:\n https://github.com/go-gorm/gorm\n\n[raised on Reddit]:\n https://www.reddit.com/r/golang/comments/1rv65k9/comment/obdrohe/\n\n[single-store version]:\n https://github.com/rednafi/examples/tree/main/repository-transactions\n\n[cross-store version]:\n https://github.com/rednafi/examples/tree/main/cross-repository-transactions\n\n[a repository layer on top of sqlc is worth it]:\n /shards/2026/03/repository-layer-over-sqlc/\n\n[handle transactions when the interface hides storage details]:\n /shards/2026/03/transactions-with-repository-pattern/\n\n[Patterns of Enterprise Application Architecture]:\n https://martinfowler.com/eaaCatalog/\n\n[dependency inversion principle]:\n https://en.wikipedia.org/wiki/Dependency_inversion_principle\n\n[Go Proverbs]:\n https://go-proverbs.github.io/\n\n[passing locked state into a closure]:\n /go/mutex-closure/\n\n[interface guard]:\n /go/interface-guards/",
"title": "Repositories, transactions, and unit of work in Go"
}