Designing Transaction Management in thunderID
When building backend systems, database transactions are easy to underestimate. At first, a repository method writes one record, another…
Designing Transaction Management in thunderID
When building backend systems, database transactions are easy to underestimate. At first, a repository method writes one record, another repository method writes another record, and everything looks simple. But as the product grows, business operations rarely stay isolated. A single user action may need to create an application, update related metadata, create OAuth clients, persist secrets, write configuration records, and trigger other domain-level operations.
That is when transaction management becomes an architectural concern, not just a database concern.
In thunderID, we had to think carefully about how transactions should be handled across services, stores, and domain boundaries. The goal was not just to “add transactions.” The goal was to introduce a transaction model that fits thunderID’s architecture, keeps business logic clean, preserves service boundaries, and works reliably in production.
The Problem We Wanted to Solve
thunderID follows a layered backend architecture where services coordinate business logic and stores handle persistence. This separation is important because it keeps domain behavior out of the persistence layer and avoids tightly coupling one component to another component’s database tables.
The problem appears when a single business operation touches multiple stores or even multiple services.
For example, a high-level operation may need to persist several related entities. If one write succeeds and a later write fails, the system must not be left in a partially updated state. Either the whole operation should succeed, or the whole operation should be rolled back.
Without a common transaction strategy, each service would have to manage this manually. That leads to repeated transaction boilerplate, inconsistent rollback behavior, and a higher risk of subtle production bugs.
So we needed a design that could provide atomicity across a full business operation while still keeping thunderID’s service and store boundaries clean.
The First Question: Where Should Transactions Live?
The most important design question was where transaction boundaries should be defined.
There were three broad possibilities.
Option 1: Store-Level Transactions
The first option was to let each store method handle its own transaction. Every persistence method would start a transaction, perform its database operation, and commit or roll back independently.
At first glance, this is simple. The store owns database access, so it seems natural for the store to own the transaction too.
But this approach breaks down when a business operation spans multiple stores.
If one store method commits successfully and the next store method fails, the first change has already been committed. There is no clean way to roll back the full business operation. This gives us transaction safety at the individual query level, but not at the business-operation level.
That was not enough for thunderID.
Store-level transactions are useful for very small, isolated persistence operations. But they do not provide cross-store atomicity. Since thunderID needed transaction safety across complete service operations, this option was not the right fit.
Option 2: Service-Level Transactions with Direct Store Access
The second option was to define transactions at the service layer, but allow a service to call multiple stores directly, even stores that belong to other domains or components.
This solves part of the atomicity problem. A service could begin a transaction, call several stores, and commit only after all operations succeed.
However, it introduces a different architectural problem.
If services start directly reaching into other components’ stores, service boundaries become weak. Business logic that should belong to another service can be bypassed. Over time, this creates tight coupling between domains, makes the code harder to maintain, and increases the risk that important validation or domain rules are skipped.
For thunderID, this was a major concern. We wanted to preserve the rule that services talk to services, and stores remain behind their owning service boundary.
So even though this option could provide database atomicity, it was not ideal from an architecture point of view.
Option 3: Service-Level Transactions with Auto-Detection
The third option was to keep transaction boundaries at the service layer, but allow nested service calls to automatically reuse an existing transaction.
This became the preferred design.
In this model, the top-level service starts a transaction for a business operation. If that service calls another service, the nested service does not blindly start a separate transaction. Instead, it detects that a transaction is already active in the current execution context and reuses it.
This gives us the best balance:
- Transaction boundaries remain aligned with business operations.
- Services can safely call other services.
- Stores do not need to know whether they are running inside a transaction or not.
- Nested operations participate in the same atomic unit of work.
- Component boundaries remain intact.
- Transaction boilerplate is centralized.
This was the core idea behind introducing a Transactioner.
Why We Introduced a Transactioner
The Transactioner was introduced as a small but important system-level abstraction responsible for managing transaction lifecycle.
Instead of making every service manually begin, commit, and roll back database transactions, services delegate that responsibility to the Transactioner.
The service only defines the unit of work: “run this operation transactionally.”
The Transactioner handles the rest.
This abstraction gives us several benefits.
First, it keeps transaction management consistent across the codebase. Every transactional operation follows the same lifecycle.
Second, it keeps services focused on business logic. A service should express what needs to happen, not repeatedly implement low-level transaction handling.
Third, it enables nested transaction reuse. If a transaction already exists, the Transactioner can detect it and avoid starting another independent transaction.
Fourth, it gives us a single place to evolve transaction behavior later. If we need better logging, tracing, metrics, panic recovery, or database-specific behavior, we can improve the Transactioner without rewriting every service.
How the Transaction Flow Works
The transaction flow is intentionally simple.
A service asks the Transactioner to run an operation transactionally. The Transactioner first checks whether the current execution context already has an active transaction.
If a transaction already exists, the Transactioner reuses it. This is what allows nested service calls to participate in the same transaction.
If no transaction exists, the Transactioner starts a new database transaction and attaches it to the execution context. All downstream store calls then use that context. Because the transaction is available through the context, stores can transparently execute their database operations using the active transaction.
The service itself does not need to pass transaction objects manually between every method. It simply passes the context it already receives and uses.
This keeps the transaction boundary explicit at the service level while keeping transaction propagation lightweight.
How Commit Is Handled
Commit happens only when the Transactioner owns the transaction.
This distinction is important.
If a service starts a top-level transactional operation, the Transactioner creates a new database transaction. When the operation completes successfully, the Transactioner commits the transaction.
But if the service is called inside an already active transaction, it does not commit anything by itself. It is participating in a transaction owned by an outer service call. In that case, the outermost Transactioner is responsible for the final commit.
This avoids a common nested-transaction problem: an inner operation should not commit while the outer operation may still fail later.
The final commit should happen only when the full business operation has completed successfully.
How Rollback Is Handled
Rollback follows the same ownership rule.
If the Transactioner started the transaction and the operation fails, it rolls the transaction back.
Failure can happen because a store operation returns an error, a nested service returns an error, or the business logic decides that the operation cannot continue. In all of those cases, the Transactioner ensures that the transaction is not committed.
If the transaction was inherited from an outer operation, the nested service does not roll it back independently. Instead, it returns the error upward. The outer transaction owner receives the error and performs the rollback.
This keeps rollback behavior predictable.
There is one owner for the transaction lifecycle. Nested services participate in the transaction, but they do not independently commit or roll back the shared transaction.
Why Context-Based Propagation Made Sense
A key part of the design was using the execution context to propagate the active transaction.
This works well because context already flows through service and store calls. It is the natural carrier for request-scoped information.
By attaching the active transaction to the context, we avoid passing transaction objects through every service and store method. This reduces API noise and avoids making transaction handling part of every business method signature.
It also makes store behavior transparent. A store method can use the active transaction when one exists, or fall back to the normal database connection when no transaction exists.
That means the same store method can work in both transactional and non-transactional flows.
Why We Did Not Choose Saga
The Saga pattern came up because it is a well-known approach for managing consistency in distributed systems.
A saga breaks a larger business process into a sequence of local transactions. Each step commits independently. If a later step fails, earlier steps are undone using compensating actions.
This is useful when a workflow spans multiple services, databases, or external systems where a single database transaction is not possible.
But thunderID’s immediate transaction problem was not that kind of distributed workflow. The main need was to maintain atomicity within a backend service boundary using a database transaction.
Using Saga for this would have added unnecessary complexity. We would need to define compensating actions, handle partial progress, manage retries, and reason about eventual consistency.
That is valuable for true distributed workflows, but it was too heavy for thunderID’s database transaction use case.
So Saga was not the right default model.
Why We Did Not Choose Two-Phase Commit
Another distributed transaction approach is Two-Phase Commit.
Two-Phase Commit coordinates multiple participants and tries to ensure that all of them either commit or roll back together. It provides stronger atomicity across distributed resources than Saga, but it comes with major operational trade-offs.
It can be blocking. It requires coordination between participants. It is harder to operate reliably in modern cloud-native systems. It also assumes that all participants can support the protocol properly.
For thunderID’s use case, that was unnecessary. We were not trying to coordinate a distributed transaction across multiple independent resource managers. We needed a clean service-level abstraction over normal database transactions.
Two-Phase Commit would have solved a problem we did not actually have, while introducing a lot of complexity we did not want.
Supporting Non-Database Modes
Another practical consideration was that not every store mode necessarily needs database transactions.
A production system may support different persistence modes or configurations. Some modes may use a relational database, while others may use file-based or declarative storage where database transaction handling does not apply.
The Transactioner abstraction helps here too.
Instead of forcing every service to care about the current persistence mode, we can provide a transaction implementation appropriate to the configured backend. For database-backed stores, the Transactioner manages real database transactions. For modes that do not need transactional behavior, a no-operation implementation can preserve the same service-level programming model without doing database transaction work.
This keeps the service layer consistent across storage modes.
The Final Design
The final design can be summarized as follows:
- Transaction boundaries belong at the service layer.
- Stores should not independently own business-level transactions.
- Services should continue to call other services rather than bypassing service boundaries.
- Nested service calls should automatically reuse an existing transaction.
- The transaction lifecycle should be centralized in a Transactioner.
- Commit should happen only at the outermost transaction owner when the full operation succeeds.
- Rollback should happen when the transaction owner sees a failure.
- Stores should transparently use the active transaction from the execution context when one exists.
This design gives thunderID a clean transaction model without over-engineering it as a distributed transaction system.
What This Gives Us in Production
The result is a transaction architecture that is simple, predictable, and aligned with the rest of the backend design.
It gives us atomicity across multi-step business operations. It preserves service boundaries. It avoids transaction boilerplate in every service. It allows nested services to compose naturally. It provides one place to improve transaction behavior over time.
Most importantly, it matches the actual problem thunderID needed to solve.
Saga and Two-Phase Commit are valuable patterns, but they are designed for distributed transaction problems. thunderID’s immediate need was different: consistent local database transaction management across service-layer operations.
That is why the Transactioner was the right abstraction.
It is small enough to understand, central enough to enforce consistency, and flexible enough to support future evolution.
메타데이터
- post_id
- 9606e67fddad
- slug
- designing-transaction-management-in-thunderid-9606e67fddad
- url
- https://medium.com/@jihanjeeth/designing-transaction-management-in-thunderid-9606e67fddad
- canonical_url
- https://medium.com/@jihanjeeth/designing-transaction-management-in-thunderid-9606e67fddad
- author_url
- https://medium.com/@jihanjeeth
- status
- ok
- fetched_at
- 2026-06-24 13:29:15