Modular Monolith + Hexagonal + DDD: Draw Boundaries That Are Just Enough, Before You Split Into Services
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
There are two popular ways to die with a backend. The first: split into microservices on day one, when nobody knows where the domain boundaries are. The result is a distributed system where changing one field means editing three services, releasing them in order, and debugging by reading logs in four places. The second: a monolith that grows with no rules, where OrderService reaches straight into the tables of UserService, and nobody dares drop a column.
Both have the same root: wrong boundaries, or no boundaries. Microservices only make a wrong boundary more expensive, because every boundary is now a network. A monolith with no rules makes boundaries disappear.
This post is about the road in between: the modular monolith. One deployable, many modules, boundaries that are real and checked by a machine rather than promised in a meeting. Hexagonal keeps the dependency rule inside each module. DDD, used in moderation, helps find where to cut. Every example is implemented twice, Go/Gin and NestJS, on the same tour-booking domain with two sales channels (B2C for retail customers, B2B for agencies), to show that the ideas don’t depend on the language while the tools that enforce the rules do.
How to read this post: each piece of architecture is an answer to a specific problem. If you haven’t hit that problem yet, that piece isn’t worth its cost. The “price you pay” section at the end isn’t there to complete the set.
Modular monolith: the boundary is the product
A modular monolith is not “a monolith with nice folders”. It’s a monolith with three hard rules:
- A module owns its data. The tables of the
bookingmodule are read and written only bybooking. If another module needs the data, it asks through the public interface: no cross-moduleJOINs, no cross-module foreign keys. - Modules talk only through a public surface. Each module has one door: a few interfaces, a few data types, a few events. Everything else is internal.
- Boundaries are checked by a machine. A violation must turn the build or the lint red. A convention in the README survives exactly until the first deadline.
The unit of division is the DDD bounded context: an area in which every word has exactly one meaning. “Buyer” in booking is the person who booked the tour (a retail customer or an agency); in payments it’s the cardholder with a saved payment method; in agency it’s an agency with a contract, a commission and a credit limit. The three can share an ID but should not share a struct. When you catch yourself forcing three meanings into one class, that’s a boundary crying for help.
This is why Martin Fowler calls the strategy “Monolith First”: most successful microservice systems started as a monolith and only split after the boundaries became clear. Go the other way, splitting before you understand the domain, and you’re guessing at boundaries and paying infrastructure costs for every wrong guess. A modular monolith makes wrong guesses cheap: changing a boundary is a refactor inside one repo, not a migration between services.
Hexagonal: one rule
Hexagonal Architecture (Alistair Cockburn calls it Ports and Adapters) is usually drawn as a hexagon and sounds like philosophy. In practice it is one dependency rule:
Business code imports nothing from the outside world. The outside world imports the business code.
[HTTP / Gin / Nest controller] [Postgres repo] [Payments client]
│ (inbound adapter) ▲ (outbound adapter) ▲
▼ │ │
┌─────────────────────────────────────────────────────────────────────┐
│ application: use case ── calls ──▶ PORT (interface defined by the module)
│ domain: entity, value object, event, rule │
└─────────────────────────────────────────────────────────────────────┘
- A port is an interface defined by the business module, in the language of the business:
Guarantee.Secure(...), notStripeClient.CreatePaymentIntent(...). - An adapter is concrete code at the edge: an HTTP controller, a Postgres repository, a client that calls another module. It implements or calls a port, and translates between the two worlds.
The practical benefit isn’t “swapping the database is easy” (almost nobody does that). It’s two other things: use cases can be tested without a network or a DB (swap the port for a fake), and the framework doesn’t leak into the domain, so upgrading Gin or Nest doesn’t touch business rules.
Hexagonal answers “how is code inside one module arranged”. Modular monolith answers “how do modules sit next to each other”. Different questions, and they reinforce each other: the outbound adapter is exactly where one module calls another, and that’s where you translate language between two contexts.
DDD in moderation: what to take, what to leave
DDD has two halves. Strategic design (bounded contexts, ubiquitous language, context maps) is the half with the biggest return and it’s nearly free: it’s just a conversation with someone who understands the business, plus a diagram. Tactical design (entities, value objects, aggregates, domain events, repositories) is the toolkit you implement in code, and it’s where over-engineering happens easily.
A tactical set that’s enough for most modules with real logic:
- An aggregate with a single entry point: every state change goes through its methods, so invariants live in one place.
Booking.Confirm()checks the state before changing it, and nobody setsstatus = "confirmed"from outside. - A value object for a small concept with its own rules:
Terms(the pricing terms of a sales channel),Money. Created through a validating constructor, immutable afterwards. - A domain event when a change has consequences in another module:
BookingConfirmed, instead of makingbookingknow how to send email.
What to leave out until you have a reason: event sourcing, CQRS with separate read/write models, a saga framework. These are tools for specific problems (full audit, read models very different from write models, distributed transactions). If you really need a saga, the post on designing booking with the saga pattern goes deep on that part.
And more important: not every module needs tactical DDD. A module managing the tour catalog that is CRUD should just stay CRUD. The end of this post returns to that point.
Example domain: booking a tour, two sales channels
This example is a simplified model to illustrate the architecture, not the design of a real system. I chose travel because it has a situation you run into all the time: the same concept, two sets of rules. Retail customers (B2C) and agencies (B2B) both “book a tour”, but:
| B2C (retail customer) | B2B (agency) | |
|---|---|---|
| Price | Retail price | Net price: retail minus the commission per contract |
| Payment guarantee | A hold on a card | The agency’s credit-limit hold |
| Who knows the rule | payments | agency |
The common wrong way: one Booking class with an isB2B flag and if statements scattered everywhere. The better way: the Booking core keeps the shared rules (no booking a departed tour, a valid number of seats, the lifecycle of the status), and what differs goes through two extension points: Terms (price) and one payment-guarantee port with two adapters. Adding a third channel, say an OTA partner, means adding Terms and an adapter, not touching the core. For much larger travel-ticketing systems, the post on system design interview: a travel ticketing system looks at it from a different angle.
Four modules, small enough to read, real enough to have boundaries worth discussing:
booking: creates and confirms tour bookings. Has rules: no booking a departed tour, at least one seat, only a pending booking can be confirmed.payments: holds money on a card for retail customers.agency: agency contracts (commission) and the credit limit.notifications: sends a notification when a booking is confirmed.
The flow: the buyer books a tour → booking determines the pricing terms for the channel (for an agency it asks agency for the commission) → computes the total → requests a payment guarantee (payments for B2C, agency for B2B) → confirms and saves → emits the BookingConfirmed event → notifications listens and sends a message. booking doesn’t know notifications exists.
The Go/Gin version
Go has a free boundary-enforcement tool that few people use to the full: nested internal directories. The compiler rule: a package under x/internal/... can be imported only by code inside the tree rooted at x/. Put internal inside each module and the module’s guts are locked off from the rest of the repo, at compile level.
internal/
booking/
booking.go // public surface: NewModule, RegisterRoutes
internal/
domain/ // Booking, Terms, event, business errors
app/ // use cases + ports
httpadapter/ // Gin handler
postgres/ // repository
paymentsadapter/ // translates to the payments module (B2C)
agencyadapter/ // translates to the agency module (B2B)
payments/
payments.go // interface Service + public request/response
internal/...
agency/
agency.go
internal/...
notifications/
notifications.go
internal/...
cmd/api/main.go // composition root: the only place that wires everything
From notifications or payments, writing import ".../internal/booking/internal/domain" is a compile error (“use of internal package not allowed”). No lint, no discipline needed.
Domain: imports nothing beyond the stdlib
package domain
import (
"errors"
"time"
)
var (
ErrNoSeats = errors.New("seats must be at least 1")
ErrDeparturePassed = errors.New("departure is in the past")
ErrInvalidCommission = errors.New("commission must be in [0, 100)")
ErrInvalidTransition = errors.New("invalid status transition")
)
type ID string
type Kind string
const (
KindRetail Kind = "retail" // B2C
KindAgency Kind = "agency" // B2B
)
// Terms is a value object: the pricing terms of one sales channel.
type Terms struct {
kind Kind
commissionPct int64
}
func RetailTerms() Terms { return Terms{kind: KindRetail} }
func AgencyTerms(commissionPct int64) (Terms, error) {
if commissionPct < 0 || commissionPct >= 100 {
return Terms{}, ErrInvalidCommission
}
return Terms{kind: KindAgency, commissionPct: commissionPct}, nil
}
func (t Terms) Kind() Kind { return t.kind }
// Price: B2C pays the retail price, B2B pays the net price after commission.
func (t Terms) Price(unitVND int64, seats int) int64 {
gross := unitVND * int64(seats)
return gross - gross*t.commissionPct/100
}
type Status string
const (
StatusPending Status = "pending"
StatusConfirmed Status = "confirmed"
)
type Booking struct {
id ID
buyerID string
terms Terms
departure time.Time
seats int
total int64
status Status
events []Event
}
func Book(id ID, buyerID string, terms Terms, departure time.Time, seats int,
unitVND int64, now time.Time) (*Booking, error) {
if seats < 1 {
return nil, ErrNoSeats
}
if !departure.After(now) {
return nil, ErrDeparturePassed
}
return &Booking{
id: id, buyerID: buyerID, terms: terms, departure: departure, seats: seats,
total: terms.Price(unitVND, seats), status: StatusPending,
}, nil
}
func (b *Booking) Confirm() error {
if b.status != StatusPending {
return ErrInvalidTransition
}
b.status = StatusConfirmed
b.events = append(b.events, BookingConfirmed{BookingID: b.id, BuyerID: b.buyerID, Kind: b.terms.Kind()})
return nil
}
func (b *Booking) ID() ID { return b.id }
func (b *Booking) Total() int64 { return b.total }
// PullEvents returns the recorded events and clears them from the aggregate.
func (b *Booking) PullEvents() []Event {
evs := b.events
b.events = nil
return evs
}
type Event interface{ EventName() string }
type BookingConfirmed struct {
BookingID ID
BuyerID string
Kind Kind
}
func (BookingConfirmed) EventName() string { return "booking.confirmed" }
The fields of Booking are lowercase: code outside the package can’t set them directly, only go through Book and Confirm. That’s an “aggregate with a single entry point” in Go, with no extra concept needed. The entire B2C/B2B difference in the domain is contained in Terms.Price: no if isB2B in Booking.
Application: use case and port, the port sits on the consumer side
Go has a convention that fits Hexagonal well: an interface is defined where it’s used, not where it’s implemented. The port sits right in the app package.
package app
import (
"context"
"errors"
"fmt"
"time"
"example.com/shop/internal/booking/internal/domain"
)
var (
ErrUnknownChannel = errors.New("unknown sales channel")
ErrGuaranteeRefused = errors.New("payment guarantee refused")
)
type Repository interface {
Save(ctx context.Context, b *domain.Booking) error
}
// Guarantee is booking's language: "secure the payment". Card or credit line is the adapter's business.
type Guarantee interface {
Secure(ctx context.Context, buyerID string, amountVND int64) (GuaranteeRef, error)
}
type GuaranteeRef string
// Contracts: booking only needs the commission, not whatever else the agency contract contains.
type Contracts interface {
CommissionPct(ctx context.Context, agencyID string) (int64, error)
}
type EventPublisher interface {
Publish(ctx context.Context, events ...domain.Event) error
}
type BookCommand struct {
BuyerID string
Kind domain.Kind
Departure time.Time
Seats int
UnitPriceVND int64
}
type BookTour struct {
Repo Repository
Retail Guarantee // B2C: hold money on a card
Agency Guarantee // B2B: hold the agency's credit limit
Contracts Contracts
Events EventPublisher
Now func() time.Time
NewID func() domain.ID
}
func (uc *BookTour) Handle(ctx context.Context, cmd BookCommand) (domain.ID, error) {
terms, guarantee, err := uc.channel(ctx, cmd)
if err != nil {
return "", err
}
b, err := domain.Book(uc.NewID(), cmd.BuyerID, terms, cmd.Departure, cmd.Seats, cmd.UnitPriceVND, uc.Now())
if err != nil {
return "", err
}
if _, err := guarantee.Secure(ctx, cmd.BuyerID, b.Total()); err != nil {
return "", fmt.Errorf("secure payment: %w", err)
}
if err := b.Confirm(); err != nil {
return "", err
}
if err := uc.Repo.Save(ctx, b); err != nil {
return "", err
}
if err := uc.Events.Publish(ctx, b.PullEvents()...); err != nil {
return "", err
}
return b.ID(), nil
}
// channel picks the pricing terms and the payment guarantee for the sales channel.
func (uc *BookTour) channel(ctx context.Context, cmd BookCommand) (domain.Terms, Guarantee, error) {
switch cmd.Kind {
case domain.KindRetail:
return domain.RetailTerms(), uc.Retail, nil
case domain.KindAgency:
pct, err := uc.Contracts.CommissionPct(ctx, cmd.BuyerID)
if err != nil {
return domain.Terms{}, nil, fmt.Errorf("load contract: %w", err)
}
terms, err := domain.AgencyTerms(pct)
return terms, uc.Agency, err
default:
return domain.Terms{}, nil, ErrUnknownChannel
}
}
This is the point of Hexagonal worth looking at closely: one port, Guarantee, two adapters. The use case knows nothing about cards or credit lines, only “secure the payment”. Now and NewID are injected so a use-case test can pin the time. Note one ugly thing: if Save fails after Secure succeeded, the hold or the credit reservation remains. That’s a transaction problem between two modules, and I come back to it in the section on how modules talk to each other.
Adapter: Gin is just a shell
package httpadapter
type Handler struct{ bookUC *app.BookTour }
func New(uc *app.BookTour) *Handler { return &Handler{bookUC: uc} }
func (h *Handler) Register(r gin.IRouter) {
r.POST("/bookings", h.book(domain.KindRetail))
r.POST("/agency/bookings", h.book(domain.KindAgency))
}
type bookRequest struct {
BuyerID string `json:"buyerId" binding:"required"` // in practice, taken from the auth token
Departure time.Time `json:"departure" binding:"required"`
Seats int `json:"seats" binding:"required"`
UnitPriceVND int64 `json:"unitPriceVnd" binding:"gte=0"`
}
func (h *Handler) book(kind domain.Kind) gin.HandlerFunc {
return func(c *gin.Context) {
var req bookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id, err := h.bookUC.Handle(c.Request.Context(), app.BookCommand{
BuyerID: req.BuyerID, Kind: kind, Departure: req.Departure,
Seats: req.Seats, UnitPriceVND: req.UnitPriceVND,
})
switch {
case err == nil:
c.JSON(http.StatusCreated, gin.H{"id": id})
case errors.Is(err, domain.ErrNoSeats), errors.Is(err, domain.ErrDeparturePassed):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
case errors.Is(err, app.ErrGuaranteeRefused):
c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
}
}
}
The only place that knows “which business error becomes which HTTP status” and “which route is which channel” is the adapter. Swap Gin for gRPC and you write a new adapter; the use case doesn’t change a line.
The edge between two modules: an adapter that translates language
payments and agency each expose a small interface in their module root file. booking doesn’t use them directly in the use case but wraps them in adapters:
package paymentsadapter
type Card struct{ svc payments.Service }
func New(svc payments.Service) Card { return Card{svc: svc} }
func (g Card) Secure(ctx context.Context, buyerID string, amountVND int64) (app.GuaranteeRef, error) {
hold, err := g.svc.Hold(ctx, payments.HoldRequest{Payer: buyerID, Amount: amountVND})
if err != nil {
return "", err
}
return app.GuaranteeRef(hold.HoldID), nil
}
package agencyadapter
type Adapter struct{ svc agency.Service }
func New(svc agency.Service) Adapter { return Adapter{svc: svc} }
// Secure implements app.Guarantee for the B2B channel: holds the agency's credit limit.
func (a Adapter) Secure(ctx context.Context, buyerID string, amountVND int64) (app.GuaranteeRef, error) {
r, err := a.svc.ReserveCredit(ctx, agency.ReserveRequest{AgencyID: buyerID, Amount: amountVND})
if errors.Is(err, agency.ErrLimitExceeded) {
return "", app.ErrGuaranteeRefused // an agency error becomes a booking error
}
if err != nil {
return "", err
}
return app.GuaranteeRef(r.ReservationID), nil
}
// CommissionPct implements app.Contracts.
func (a Adapter) CommissionPct(ctx context.Context, agencyID string) (int64, error) {
c, err := a.svc.Contract(ctx, agencyID)
return c.CommissionPct, err
}
This is exactly the DDD anti-corruption layer, small enough to be a couple of ten-line structs. If payments renames Payer to Account, only the first file breaks. If agency changes how it reports a credit-limit error, only the second file breaks.
Composition root
// cmd/api/main.go
func main() {
db := mustOpenDB()
bus := eventbus.New() // in-process
paySvc := payments.NewModule(db).Service()
agencySvc := agency.NewModule(db).Service()
bookingMod := booking.NewModule(db, paySvc, agencySvc, bus)
notifications.NewModule(bus) // registers handlers on the bus
r := gin.Default()
bookingMod.RegisterRoutes(r)
_ = r.Run(":8080")
}
main is the only place that knows all the modules. That’s deliberate: the dependencies between modules are visible in one file.
The NestJS version
NestJS has modules and DI built in, but nothing enforces boundaries at compile time. Nest modules enforce only at runtime: if a provider isn’t in exports, another module injecting it fails at startup. A TypeScript import between files, on the other hand, anyone can type. So with Nest, lint-based checking is mandatory, not decoration (see the later section).
src/modules/
booking/
index.ts // public surface: BookingModule, event type
booking.module.ts
domain/
terms.ts
booking.ts
events.ts
application/
ports.ts
book-tour.use-case.ts
infrastructure/
typeorm-booking.repository.ts
payments-facade.gateway.ts // B2C
agency-facade.gateway.ts // B2B
nest-event.publisher.ts
presentation/
booking.controller.ts
payments/
index.ts // PaymentsModule, PaymentsFacade
agency/
index.ts // AgencyModule, AgencyFacade
notifications/
Domain: plain TypeScript
// domain/terms.ts
export class InvalidCommissionError extends Error {}
export type Kind = 'retail' | 'agency';
// Value object: the pricing terms of one sales channel.
export class Terms {
private constructor(
readonly kind: Kind,
private readonly commissionPct: number,
) {}
static retail(): Terms {
return new Terms('retail', 0);
}
static agency(commissionPct: number): Terms {
if (commissionPct < 0 || commissionPct >= 100) throw new InvalidCommissionError();
return new Terms('agency', commissionPct);
}
// B2C pays the retail price, B2B pays the net price after commission.
price(unitVnd: number, seats: number): number {
const gross = unitVnd * seats;
return gross - Math.floor((gross * this.commissionPct) / 100);
}
}
// domain/booking.ts
export class NoSeatsError extends Error {}
export class DeparturePassedError extends Error {}
export class InvalidTransitionError extends Error {}
type Status = 'pending' | 'confirmed';
export class Booking {
private events: DomainEvent[] = [];
private constructor(
readonly id: string,
readonly buyerId: string,
readonly terms: Terms,
readonly departure: Date,
readonly seats: number,
readonly total: number,
private status: Status,
) {}
static book(
p: { id: string; buyerId: string; terms: Terms; departure: Date; seats: number; unitPriceVnd: number },
now: Date,
): Booking {
if (p.seats < 1) throw new NoSeatsError();
if (p.departure <= now) throw new DeparturePassedError();
const total = p.terms.price(p.unitPriceVnd, p.seats);
return new Booking(p.id, p.buyerId, p.terms, p.departure, p.seats, total, 'pending');
}
confirm(): void {
if (this.status !== 'pending') throw new InvalidTransitionError();
this.status = 'confirmed';
this.events.push(new BookingConfirmed(this.id, this.buyerId, this.terms.kind));
}
pullEvents(): DomainEvent[] {
const evs = this.events;
this.events = [];
return evs;
}
}
// domain/events.ts
export interface DomainEvent {
readonly name: string;
}
export class BookingConfirmed implements DomainEvent {
readonly name = 'booking.confirmed';
constructor(
readonly bookingId: string,
readonly buyerId: string,
readonly kind: Kind,
) {}
}
These files have no @Injectable, no @nestjs/* import, no TypeORM import. The domain is tested with Jest without Test.createTestingModule.
Port: an abstract class as the injection token
TypeScript interfaces vanish after compilation, so they can’t serve as DI tokens. Two common options are a Symbol/string token or an abstract class, which is both a type and a runtime value. Since there are two ways to guarantee a payment, we need two tokens; they have the same shape, and that’s a little repetition you have to accept when doing DI by class:
// application/ports.ts
export abstract class BookingRepository {
abstract save(b: Booking): Promise<void>;
}
// B2C: hold money on a card.
export abstract class RetailGuarantee {
abstract secure(buyerId: string, amountVnd: number): Promise<string>;
}
// B2B: hold the agency's credit limit.
export abstract class AgencyGuarantee {
abstract secure(buyerId: string, amountVnd: number): Promise<string>;
}
export abstract class Contracts {
abstract commissionPct(agencyId: string): Promise<number>;
}
export abstract class EventPublisher {
abstract publish(events: DomainEvent[]): Promise<void>;
}
export class GuaranteeRefusedError extends Error {}
// application/book-tour.use-case.ts
@Injectable()
export class BookTourUseCase {
constructor(
private readonly repo: BookingRepository,
private readonly retail: RetailGuarantee,
private readonly agency: AgencyGuarantee,
private readonly contracts: Contracts,
private readonly events: EventPublisher,
) {}
async execute(cmd: {
buyerId: string;
kind: Kind;
departure: Date;
seats: number;
unitPriceVnd: number;
}): Promise<string> {
const { terms, guarantee } = await this.channel(cmd.kind, cmd.buyerId);
const booking = Booking.book(
{ id: randomUUID(), buyerId: cmd.buyerId, terms, departure: cmd.departure, seats: cmd.seats, unitPriceVnd: cmd.unitPriceVnd },
new Date(),
);
await guarantee.secure(cmd.buyerId, booking.total);
booking.confirm();
await this.repo.save(booking);
await this.events.publish(booking.pullEvents());
return booking.id;
}
// Picks the pricing terms and the payment guarantee for the sales channel.
private async channel(kind: Kind, buyerId: string) {
switch (kind) {
case 'retail':
return { terms: Terms.retail(), guarantee: this.retail };
case 'agency':
return { terms: Terms.agency(await this.contracts.commissionPct(buyerId)), guarantee: this.agency };
}
}
}
@Injectable() is the only Nest thing at this layer, and it’s just metadata for DI. If you want domain and application even cleaner, you can drop even this decorator and declare the provider with useFactory in the module.
Adapters and wiring
// infrastructure/payments-facade.gateway.ts
@Injectable()
export class PaymentsFacadeGateway implements RetailGuarantee {
constructor(private readonly payments: PaymentsFacade) {}
async secure(buyerId: string, amountVnd: number): Promise<string> {
const hold = await this.payments.hold({ payer: buyerId, amount: amountVnd });
return hold.holdId; // translate payments language -> booking language
}
}
// infrastructure/agency-facade.gateway.ts
@Injectable()
export class AgencyFacadeGateway implements AgencyGuarantee, Contracts {
constructor(private readonly agency: AgencyFacade) {}
async secure(buyerId: string, amountVnd: number): Promise<string> {
try {
const r = await this.agency.reserveCredit({ agencyId: buyerId, amount: amountVnd });
return r.reservationId;
} catch (e) {
if (e instanceof CreditLimitExceededError) throw new GuaranteeRefusedError(); // agency error -> booking error
throw e;
}
}
async commissionPct(agencyId: string): Promise<number> {
return (await this.agency.contract(agencyId)).commissionPct;
}
}
// infrastructure/nest-event.publisher.ts
@Injectable()
export class NestEventPublisher implements EventPublisher {
constructor(private readonly emitter: EventEmitter2) {}
async publish(events: DomainEvent[]): Promise<void> {
for (const e of events) await this.emitter.emitAsync(e.name, e);
}
}
// booking.module.ts
@Module({
imports: [PaymentsModule, AgencyModule, TypeOrmModule.forFeature([BookingOrmEntity])],
controllers: [BookingController],
providers: [
BookTourUseCase,
{ provide: BookingRepository, useClass: TypeOrmBookingRepository },
{ provide: RetailGuarantee, useClass: PaymentsFacadeGateway },
{ provide: AgencyGuarantee, useClass: AgencyFacadeGateway },
{ provide: Contracts, useExisting: AgencyGuarantee },
{ provide: EventPublisher, useClass: NestEventPublisher },
],
})
export class BookingModule {}
Reading the provide: lines tells you at once “which adapter is plugged into which port”, including the fact that B2C and B2B go through two different paths. useExisting for Contracts reuses the same adapter instance as AgencyGuarantee. PaymentsModule and AgencyModule must export exactly their facade and only that: that’s the public surface.
notifications listens for the event and doesn’t import booking’s use case:
@Injectable()
export class BookingConfirmedHandler {
@OnEvent('booking.confirmed')
async handle(e: BookingConfirmed): Promise<void> {
// send the notification: retail customers get a confirmation email, agencies get one with a credit invoice
}
}
The BookingConfirmed type is exported by booking through index.ts. The dependency direction is notifications → booking (the listening side follows the contract of the emitting side), and there’s no reverse direction.
How modules talk to each other
Two kinds of communication, each with its own purpose:
- Synchronous calls through a public facade/interface: when you need the answer right now to continue (the hold or credit reservation happens, then the booking is confirmed). Always through a language-translating adapter, as in the examples above.
- Domain events: when the emitter doesn’t need to know who cares and doesn’t wait for a result (sending notifications, updating stats). Adding a listener doesn’t mean editing the emitter.
Two things you never do: share a table, and cross-module FKs. They’re the biggest back doors, because every boundary in the code is meaningless if two modules write to the same table. When you need another module’s data, ask through the facade or keep a read-only copy updated by events. Example: booking doesn’t JOIN to agency’s contract table to get the commission, it asks through Contracts.
One weakness has to be stated plainly: in-process events mean no delivery guarantee. If the process dies between Save and Publish, the event is lost. The standard fix is a transactional outbox: write the event to an outbox table in the same transaction as the aggregate, and a worker reads that table and publishes. If you don’t need an outbox yet, stay in-process, but know which risk you’re accepting. Likewise, a hold or a credit reservation that’s already been made while Save fails needs an undo step (compensation), and that’s where the saga idea comes in.
Enforce boundaries with tools, not culture
A boundary a machine doesn’t check isn’t a boundary yet. This is what I’d install from the very first commit.
Go: the nested internal directories above already give a compile error for free. To add direction-based rules (for example domain must not import app, must not import gin), use golangci-lint’s depguard or an architecture-checking tool like go-arch-lint, configuring the list of packages each layer may import.
NestJS: use ESLint. The simplest form is the built-in no-restricted-imports rule, applied to code outside the module:
// eslint.config.js (excerpt)
{
files: ['src/modules/payments/**', 'src/modules/agency/**', 'src/modules/notifications/**'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['**/modules/booking/domain/*', '**/modules/booking/application/*',
'**/modules/booking/infrastructure/*'],
message: 'Only import from modules/booking (index.ts).',
}],
}],
},
},
If the repo already uses Nx, @nx/enforce-module-boundaries with tags does this more neatly. eslint-plugin-boundaries and dependency-cruiser are other options. Any tool will do; what matters is that CI goes red on a violation.
One more rule worth checking: the domain doesn’t import a framework. In Go that’s depguard blocking gin in domain. In Nest it’s blocking @nestjs/* and the ORM in the domain directory.
The price you pay
This architecture isn’t free, and any post that forgets this part is selling something.
- More files, more mappers. A simple create operation can go through controller, DTO, use case, port, repository, domain ↔ row mapper. For a module that is only CRUD, that’s ritual, not value.
- Translating back and forth. Every edge between modules has a language-translating adapter. Correct as design, but it’s code you have to write and maintain.
- A learning curve. Newcomers have to understand why
Bookinghas no setter. If the team has never done DDD, tactical patterns used wrongly are worse than not using them. - Over-engineering is a real risk. DDD was born for complex domains. Apply it to a data-admin app and you pay the full cost and get nothing back. In the example above itself, if B2B differed from B2C by exactly one discount number,
Termsand the two adapters would be waste; they’re only worth it if the two channels really have different rules and will diverge further. - Boundaries can still be wrong. A modular monolith makes changing a boundary cheaper, not correct. You still need someone who understands the domain.
The rule for choosing: use full tactical DDD and Hexagonal for modules with real business rules (state, invariants, calculations). A CRUD module just stays CRUD: controller → service → repository, still inside the module boundary and still subject to import enforcement, just without a rich domain model. A codebase with both kinds of module is normal and healthy.
When splitting out a microservice is finally worth it
The wrong question is “monolith or microservices”. The right one is: is this boundary worth the price of a network? Signals worth weighing:
- Clearly different scaling: a module needs to scale independently because its load is very different from the rest (for example the retail booking endpoint spikes with promotions, while the agency endpoint stays steady).
- Different release cadence and the organization has already split into teams: two teams need to deploy independently without coordinating.
- Isolation requirements: compliance, security (for example isolating whatever touches card data), or a blast radius that a shared process can’t satisfy.
- Different technology: one part needs a very different runtime or language.
The nice thing about a modular monolith is that when one of those signals shows up, you already have the boundary: the module has a clear public surface, shares no tables, and communicates through facades and events. Splitting means replacing the facade with a network-calling client and replacing in-process events with a message broker. It’s mechanical work, not surgery.
Conversely, if the team is small, with one deploy pipeline, one database, and none of the signals above, microservices only add network latency, distributed tracing, and data-consistency problems, and solve none of the problems you actually have.
Wrap-up
Packed into three sentences:
- The boundary is the most valuable thing; get it right and enforce it with tools before thinking about cutting into services.
- Hexagonal is one rule: the business code doesn’t import the outside world. Ports sit on the business side, adapters sit at the edge and translate language.
- DDD scaled to the problem: strategic is always worth doing, tactical only where there are real rules.
Good architecture isn’t the one with the most layers. It’s the one whose cost matches the problem you have, and which leaves a path open when the problem grows.
References
The sources below are the foundation for the main ideas; read the originals rather than just trusting my summary.
- Martin Fowler, “MonolithFirst” (2015), martinfowler.com/bliki/MonolithFirst.html
- Alistair Cockburn, “Hexagonal architecture” (Ports and Adapters), alistair.cockburn.us/hexagonal-architecture
- Eric Evans, Domain-Driven Design (2003): bounded context, aggregate, anti-corruption layer
- Go command documentation, section “Internal Directories”: pkg.go.dev/cmd/go#hdr-Internal_Directories
- Kamil Grzybek, “Modular Monolith with DDD”, github.com/kgrzybek/modular-monolith-with-ddd
- Shopify Engineering, “Deconstructing the Monolith” (2019)
- NestJS documentation: Modules, Custom providers, Events (
@nestjs/event-emitter)
Keep reading
- tRPC in a NestJS Monorepo: A Type-Safe Contract from Router to HookEngineering Deep Dive
- Go Runtime in Containers: GOMAXPROCS, CPU Throttling and the Tail Latency Trap on KubernetesEngineering Deep Dive
- Go + React: Realtime Streaming with SSE, Covering Backpressure, Reconnects and a Type-Safe ContractEngineering Deep Dive
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.