3# My Journey From CRUD Developer to Building a Production-Oriented Go Backend
Understanding Transactions Through Unit of Work
In the previous section, I started separating responsibilities between transport, application, usecase, and repository. My architecture began to look more structured. However, a problem emerged that couldn't be solved by layering alone. The problem was transactions.
I already understood how to build a repository.
I also understood how a usecase utilizes multiple repositories.
Yet, I hadn't truly grasped a more critical question:
Who should control the transaction when a single business operation spans across multiple repositories?
When One Operation Is No Longer Just One Query For simple operations, transactions feel straightforward.
For example:
Update Profile ā UPDATE users ā COMMIT
The repository could simply manage its own transaction.
However, business operations are not always that simple.
Take a registration process as an example:
Register
āāā Create Credential āāā Create Profile
Now two domains are involved.
We want a behavior like
Credential succeeds Profile succeeds ā COMMIT
or:
Credential succeeds Profile fails ā ROLLBACK
It must never end up as:
Credential succeeds Profile fails
Database: Credential ā Profile ā
Because from the business operation's perspective, that registration actually failed.
Transactions Are Not Just BEGIN and COMMIT Initially, I viewed transactions as something simple:
return tx.Commit() However, when transactions start involving multiple repositories, the real problem surfaces.
Who creates tx?
If each repository creates its own:
Register āāā CredentialRepository āāā BEGIN TX
āāā ProfileRepository āāā BEGIN TX
we end up with two separate transactions.
What we actually want is:
Register ā¼ BEGIN TX āāā CredentialRepository āāā ProfileRepository ā¼ COMMIT
This means the transaction must have a higher boundary than individual repositories.
Repositories Should Not Own the Transaction Lifecycle From here, I began to realize the distinction between:
Repository responsibility and Transaction responsibility.
A repository is responsible for persistence operations.
For example:
A repository shouldn't have to decide:
- Should it BEGIN now?
- Should the transaction COMMIT?
- Should the transaction ROLLBACK?
Because the repository is unaware of the larger business operation. It only knows that it is executing a persistence action.
So Who Controls the Transaction? The answer became clear when I looked at the structure of the application layer.
The application knows that an operation requires several usecases.
For instance:
Register āāā Credential āāā Profile āāā Verification
The application knows the entire flow. Therefore, the transaction boundary should wrap around that flow:
Application Operation
ā¼ Unit of Work ā¼ BEGIN āāā Credential āāā Profile āāā Verification ā¼ COMMIT
If any step fails:
Application Operation ā¼ Unit of Work ā¼ BEGIN āāā Credential ā āāā Profile ā āāā Verify ā ā¼ ROLLBACK
This is where I began to understand the Unit of Work.
Unit of Work Is Not Business Logic This is a crucial point. I didn't want to turn the Unit of Work into a place where all logic resides.
UoW is not: "a service that performs registration"
and it is not: "a larger repository"
UoW is solely responsible for managing the transaction lifecycle.
Simply put:
An implementation might look like:
The concept is simple:
Do āāā BEGIN āāā Build dependencies using TX āāā Execute operation āāā ROLLBACK if failed āāā COMMIT if successful
Yet, this abstraction solves a major problem.
Why sql.DBTX Becomes Important There was another detail I only realized after implementing this.
Previously, my repositories might have required: *sql.DB
However, when a transaction is used, the repository must work with: *sql.Tx
If a repository only accepts *sql.DB, it cannot participate in an ongoing transaction.
I then adopted an abstraction:
That way, the repository doesn't care whether the dependency it receives is: *sql.DB or: *sql.Tx
Conceptually:
Without Transaction Repository ā *sql.DB
whereas:
Inside UoW Repository ā *sql.Tx
Yet, the repository continues to fulfill the same contract. This became a real example of how an abstraction naturally emerges out of system necessity, rather than just adding abstractions arbitrarily.
Building Repositories Using Transactions From there, I started using an approach like:
Notice that db doesn't have to be *sql.DB. It only needs to satisfy DBTX.
can be used for normal operations.
While:
is used when operating inside a transaction.
This enables the repository to be reused without needing to know who controls the transaction.
UoW Becomes the Transaction Boundary
Now, my application flow can be visualized as:
Auth Application ā¼ UoW āāā BEGIN TX ā¼ Build Repositories āāā Credential Repository āāā Profile Repository āāā Verification Repository ā¼ Execute Business Flow āāā success ā COMMIT āāā failure ā ROLLBACK
The Application knows its operation. UoW knows the transaction lifecycle.
The Repository handles persistence. The Usecase enforces business rules.
Each boundary becomes clearer.
Why Not Manage Transactions Directly in the Application Layer? This question crossed my mind. If the application already knows the entire flow, why not just write:
directly in the application layer?
Technically, you could. However, the application layer would then become coupled to database implementation details.
For example:
Now the application layer knows about:
- database
- transaction
- sql.Tx
- commit
- rollback
Even though the application layer's sole responsibility should be orchestrating business operations.
I want the application to express: "Run this operation atomically."
rather than: "Use a SQL database with this transaction mechanism."
UoW serves as a boundary that hides those lifecycle details.
Transaction Boundary vs Business Boundary
This also helped me understand that a transaction boundary is not always synonymous with a repository boundary.
For example:
- Repository A
- Repository B
- Repository C
can exist inside a single transaction:
Transaction āāāāāāāāāāā ā Repository A ā ā Repository B ā ā Repository C ā āāāāāāāāāāā Because the transaction boundary is not determined by the number of repositories. Instead, it is determined by: What must succeed or fail as a single business operation?
This mindset is far more useful than simply thinking: "Every repository must have a transaction."
Failure as Part of Design After understanding this, I started looking at errors with a fresh perspective.
For instance:
- Create Credential ā
- Create Profile ā
- Create Verification ā
The question isn't just: "How do we return the error?"
It is also: "What is the database state after this error?"
With Unit of Work:
BEGIN āāā Credential ā āāā Profile ā āāā Verification ā ā¼ ROLLBACK ā¼ No partial state
Error handling and transaction handling finally become unified in one design.
What Did I Actually Learn? Initially, I thought I was learning a pattern called Unit of Work. In reality, what I learned deeply was transaction boundaries.
I came to understand that:
Repositories don't need to own the transaction lifecycle.
Usecases shouldn't be aware of HTTP or SQL transactions.
The Application knows the larger business flow.
UoW manages the transaction lifecycle.
Repositories can work with both *sql.DB and *sql.Tx via DBTX.
Multiple domains can share a transaction without needing awareness of each other.
Rollback isn't merely error handling; it is a core mechanism to preserve consistency.
Transaction boundaries should align with the atomicity requirements of a business operation.
And most importantly:
Unit of Work is not the architectural goal. It is an answer to the problem of transaction boundaries that arise when a single business operation spans multiple persistence operations.
From Here, My Architecture Evolved Again Previously, I had:
Handler ā Usecase ā Repository ā Database
Now it started evolving into:
Handler ā Application ā Unit of Work ā Usecase ā Repository ā Database
However, I didn't add a layer just to make the architecture diagram look more complex. I added it because the system developed a genuine new requirement:
Multiple operations must run within the same transaction.
And as it turns out, solving this one problem opened up the next question:
If Application orchestrates multiple domains, where should the core business logic actually live?
That question leads to the next step: separating domain, usecase, and application orchestration without turning everything into a God Object.