# A NestJS service with four layers and a transactional outbox

> This repository is a worked example of layered domain-driven design with CQRS in NestJS, with an account domain, a notification domain, and a transactional outbox that publishes events through SQS by way of LocalStack. The repository is kept as a stable reference rather than under active feature development, and the layering is the part worth taking.

**kyhsa93/nestjs-rest-cqrs-example** — Example for Nest.js, MySQL, REST api, CQRS, DDD, Outbox with SQS (LocalStack)

- Repository: https://github.com/kyhsa93/nestjs-rest-cqrs-example
- Stars: 874 · Forks: 157
- Language: TypeScript
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/kyhsa93-nestjs-rest-cqrs-example

## One service, four layers, two domains

The project is a worked example of layered domain-driven design with CQRS in NestJS, arranged as domain, application, interface and infrastructure layers, with aggregates and domain events, a command and query separation, and a transactional outbox that publishes events through SQS, all in one runnable REST API. The stack is NestJS, TypeScript, TypeORM, MySQL 8.4, the @nestjs/cqrs package, and the AWS SDK clients for SQS, SNS, SES and Secrets Manager, with LocalStack standing in for AWS locally. There are two bounded contexts. The account domain covers opening an account, depositing, withdrawing, remitting, changing a password and closing. The notification domain sends email in response to account events. The README notes this is the author's most-starred project and that it is kept as a stable reference rather than under active feature development.

## The dependency rule and what each layer owns

The rule is that every dependency points inward, toward the domain layer, and infrastructure implements the interfaces that the domain and application layers define. The domain layer depends on nothing else. It holds the model, which is made of aggregates and entities with value objects where needed, and the core business logic lives inside it. Domain events are defined there and raised when domain logic runs, with the handlers in the application layer receiving them. The repository interface is defined in the domain layer and implemented in the infrastructure layer, which is the separated interface pattern, and a repository manages a collection of domain objects. Domain objects are created through factories rather than by calling constructors directly. The application layer depends on the domain and runs its logic: commands change state, queries only read and never touch the domain model, domain event handlers react, and integration events go to other contexts through a publisher.

## The interface and infrastructure layers

The interface layer depends on the application layer and is where HTTP or message requests land. It receives them, validates them, builds the commands and queries, executes them through the bus and returns the response, so the controllers contain no business logic at all. The infrastructure layer implements the technical details of the other layers: repositories, publishers and adapters, database access, messaging, caching and external communication. The stated reason for putting all of that there is isolation, so a technical change does not spread into the rest of the code. That is the practical payoff of the layering in a project like this one, because the AWS SDK calls, the TypeORM entities and the HTTP handlers all change for different reasons, and the domain aggregates do not change for any of them. The layer diagram the README references comes from the seedstack documentation on business layers.

## Opening an account, event to event

The worked example is POST /accounts, and the sequence is worth reading because it shows the whole pattern in one request. The command runs on the command bus to the handler, which creates the account aggregate and calls open on it, and the aggregate records an AccountOpenedEvent. The handler then saves the account, which writes the account row and the outbox row in one database transaction. After that everything is event-driven. Two hops go through the outbox and SQS: the domain event, then the integration event that the domain event handler produces. The outbox poller reads pending rows every five seconds and publishes them, the consumer receives them and dispatches the AccountOpenedEvent, the AccountOpenedHandler writes an AccountOpened integration event, and the same poll, publish and consume path runs again for that one. Finally the integration event controller dispatches AccountOpened and sends a SendEmailCommand into the notification context.

## Why the outbox exists rather than a direct publish

The guarantee the outbox gives is stated precisely: because the account row and its outbox row are committed together, an event is never published for a change that was rolled back, and a committed change is never left without its event. That is the classic dual-write problem, where writing to the database and publishing to a broker are two separate operations and one of them can succeed while the other fails. Publishing inside the transaction is not an option because a slow broker call holds a database lock, and publishing after the commit loses the event if the process dies in between. Writing the event to a table in the same transaction and letting a separate poller forward it removes both failure modes, at the cost of a delay measured in the poll interval. The README puts the interval at five seconds, which is the number to remember when you are deciding whether this suits your use case.

## The project layout and the shared libraries

The source tree shows the mapping between the layers and the modules.

```bash
src/
  account/        domain · application (command, query, event) · interface · infrastructure
  notification/   the same four layers; reacts to account integration events
  outbox/         OutboxWriter, OutboxPoller, OutboxConsumer, EventHandlerRegistry
  config.ts       environment configuration, validated on startup
libs/             shared modules: database, auth, message, secret, error, logging, discovery
test/             unit tests for command handlers and the outbox (domain tests sit next to the code)
```

Each context directory, account and notification, holds its own domain, application, interface and infrastructure code, with the application side split into command, query and event handling. The notification context reacts to account integration events. There is a separate outbox directory with the OutboxWriter, the OutboxPoller, the OutboxConsumer and an EventHandlerRegistry, which is the whole transactional outbox mechanism in one place rather than spread across contexts. The config file holds environment configuration validated on startup, so a missing variable fails the process immediately instead of at the first request. The libs directory holds shared modules for database, auth, message, secret, error, logging and discovery, and the test directory holds unit tests for command handlers and the outbox, with domain tests sitting next to the code they test.

## Running it locally with MySQL and LocalStack

The compose file wires three services together: an nginx proxy in front of the application, the Nest service built from a development Dockerfile, and the MySQL and LocalStack containers the application waits on, since both are declared with health conditions the service depends on. The environment for the Nest service points the AWS SDK at LocalStack with dummy credentials, which LocalStack accepts as any non-empty value, and the comment notes that production uses an IAM role or a task role instead. The database is MySQL 8.4 with the utf8mb4 character set and utf8mb4_unicode_ci collation configured on the server. Two queue URLs are set, and the reason is worth noting: outbox domain events get their own queue so that the OutboxConsumer does not compete with the integration-event consumer reading the other queue.

## Containers, Swagger and the checks

There are two Dockerfiles, one for development and one for production, and the production one is a two-stage build. The build stage runs on node:24-alpine, copies the manifest and lock file, installs with npm ci, copies the TypeScript configuration and the source, and runs the build. The production stage starts again from node:24-alpine with NODE_ENV set to production, installs with npm ci and the dev dependencies omitted, copies the compiled dist directory from the build stage, switches to the node user and exposes port 5000, running node on the built entry point. Swagger UI is served at the api path, for example at localhost:5000/api, so you can drive the whole flow from a browser. The quality gates are unit tests with Jest, linting, and an architecture compliance check in CI that has to score 90 or above, run with the author's backend-service-playbook harness.

## Conclusion

Use this repository as a reference for how the layers and the outbox fit together in NestJS, since the code shows the dependency rule and the two-hop event path rather than describing them, and it runs with MySQL and LocalStack on your machine. Do not treat it as a starter template, because the README says it is kept as a stable reference rather than under active feature development, and the released tags are from 2021. Verify first that the outbox pattern is what you need, since it adds a poller, a consumer and an SQS round trip in exchange for never losing an event, that the two queue URLs in the compose file are configured the way the code expects, since domain events and integration events deliberately use separate queues so the consumer does not compete for messages, and that your own project passes the same architecture compliance harness the CI gate uses.

## FAQ

### What does this NestJS example demonstrate?

Layered domain-driven design with CQRS in NestJS, arranged as domain, application, interface and infrastructure layers, with aggregates and domain events, a command and query separation, and a transactional outbox publishing events through SQS, all in one runnable REST API.

### Why does the project use a transactional outbox instead of publishing directly?

Because the account row and its outbox row are committed in the same transaction, an event is never published for a change that was rolled back, and a committed change is never left without its event. The outbox poller reads pending rows every five seconds and publishes them to SQS.

### What domains does the example cover?

An account domain with opening, depositing, withdrawing, remitting, changing a password and closing, and a notification domain that sends email in response to account events. Each keeps the same four layers in its own directory.

### What do I need to run it locally?

The compose file brings up an nginx proxy, the Nest service, MySQL 8.4 and LocalStack. LocalStack stands in for the AWS services with dummy credentials, and the AWS SDK is pointed at it with the AWS_ENDPOINT variable, while production uses an IAM role or a task role instead.

### Is this project still actively developed?

The README says it is kept as a stable reference rather than under active feature development, and that the layering and event patterns are the lasting part. The most recent tags are 2.0.0 from 2021-05-08 and an initial release from 2020, although the last push to the repository was on 2026-10-18.

## Sources

- [Issues](https://github.com/kyhsa93/nestjs-rest-cqrs-example/issues)
- [kyhsa93/nestjs-rest-cqrs-example on GitHub](https://github.com/kyhsa93/nestjs-rest-cqrs-example)
- [License: MIT](https://github.com/kyhsa93/nestjs-rest-cqrs-example/blob/main/LICENSE)
- [README](https://github.com/kyhsa93/nestjs-rest-cqrs-example/blob/main/README.md)
- [Releases](https://github.com/kyhsa93/nestjs-rest-cqrs-example/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/kyhsa93-nestjs-rest-cqrs-example
