Skip to content

Transactions sit at the use-case boundary, not inside a repository call

Transactions start and end at the use-case service — not in repositories or controllers.

nestjs-data-transactions-at-use-case

Why it matters

Failure modes if this rule is ignored
StakeIf ignored
Layer mixing
  • A transaction inside a repository = the repository knows too much. It doesn't know if it's part of a larger flow.
  • The service is the use case, and the use case is the natural transaction boundary.
Hidden coupling
  • A transaction in a controller = the controller knows about the DB. Layer violation.
Data corruption
  • If the second step fails, an orphaned site is left in the DB.

How to fix

@Transactional or unit-of-work wraps a service-level call, not a single repository call. If an operation needs two repositories — the transaction lives in the service.

Examples

Bad
ts
async createSiteWithAgent(input: CreateSiteWithAgentDto) {
  const site = await this.sitesRepo.create(input.site);      // commit 1
  const agent = await this.agentsRepo.create({               // commit 2
    ...input.agent,
    siteId: site.id,
  });
  // if the second fails, an orphaned site is left in the DB
  return { site, agent };
}
Good
ts
@Injectable()
export class SiteProvisioningService {
  constructor(
    private dataSource: DataSource,
    private sites: SitesService,
    private agents: AgentsService,
  ) {}

  async provisionSiteWithAgent(input: ProvisionInput) {
    return this.dataSource.transaction(async (tx) => {
      const site  = await this.sites.create(input.site, { tx });
      const agent = await this.agents.create({ ...input.agent, siteId: site.id }, { tx });
      return { site, agent };
    });
  }
}

Contribute

Released under the MIT License.

esc