If you want to implement Domain-Driven Design in TypeScript today, the ecosystem usually forces you into one of three frustrating corners:
- Going all-in on Event Sourcing. On one side, you have tools like Emmett.js. They are fantastic if you are building a deeply event-driven system. But pragmatically, Event Sourcing is the right architectural choice for maybe 5% of projects. For the other 95%, forcing your team to maintain an Event Store just to get clean domain boundaries is massive operational overkill.
- Drowning in OOP boilerplate. On the other side, you have framework modules like NestJS CQRS. These force you into a heavy OOP paradigm: endless decorators, classes with mutable state, and leaky dependency injection, often without enforcing a strict, proper DDD structure out of the box.
- Rolling your own in-house framework. Because the first two options are so polarizing, many teams just give up and build it themselves. You end up spending weeks hand-wiring aggregates, writing custom event buses, and maintaining brittle infrastructure code instead of shipping actual business features.
When I built noDDDe - a Domain-Driven Design framework for TypeScript - I wanted to solve this specific problem. By using pure functions (the Decider pattern) instead of heavy OOP classes, we can decouple our logic so completely that persistence becomes a configuration choice, not an architecture decision.
Today, I'm going to show you exactly how this works in practice. We are going to build a simple Auction domain, test it without a single mock, and then wire it up to both a standard SQL database and an Event Store-without changing a single line of business logic.
Step 1: Defining the Pure Domain
In traditional OOP, an Aggregate is a class that mutates its own state. In the functional Decider pattern used by noDDDe, an Aggregate is just a collection of pure functions. It has zero knowledge of where its data comes from.
First, we define our contract as TypeScript types. This single type bundle will infer everything else in our application:
import { DefineCommands, DefineEvents } from "@noddde/core";
// Map of intents that may be invoked on an Auction
export type AuctionCommand = DefineCommands<{
CreateAuction: { item: string; startingPrice: number; endsAt: Date };
PlaceBid: { bidderId: string; amount: number };
CloseAuction: void;
}>;
// Map of facts that may happen during the lifecycle of an Auction
export type AuctionEvent = DefineEvents<{
AuctionCreated: { item: string; startingPrice: number; endsAt: Date };
BidPlaced: { bidderId: string; amount: number; timestamp: Date };
BidRejected: { bidderId: string; amount: number; reason: string };
AuctionClosed: { winnerId: string | null; winningBid: number | null };
}>;
// Expresses the state of an auction at a given time
export interface AuctionState {
item: string;
startingPrice: number;
endsAt: Date;
status: "open" | "closed";
highestBid: { bidderId: string; amount: number } | null;
bidCount: number;
}
// Adapters (interfaces) that we may need to do business logic
export interface AuctionInfrastructure {
clock: { now(): Date; };
}
// Combined type contract of the Auction Aggregate (shown below)
type AuctionDef = {
state: AuctionState;
events: AuctionEvent;
commands: AuctionCommand;
infrastructure: AuctionInfrastructure;
};
Next, we write the business logic. We define the initialState, the deciders (which validate rules and return events), and the evolve functions (which fold events into the current state).
First, starting with the intial state
// The initial (zero-value) state for a new auction aggregate.
export const initialAuctionState: AuctionState = {
item: "",
startingPrice: 0,
endsAt: new Date(0),
status: "open",
highestBid: null,
bidCount: 0,
};
Next, we need to implement a decide function, which executes on command dispatch, should make a decision and emit 0, 1 or more events
// deciders/decide-place-bid.ts
import type { InferDecideHandler } from "@noddde/core";
import type { AuctionDef } from "../auction";
// The InferDecideHandler type utility helps narrow down the types of params
export const decidePlaceBid: InferDecideHandler<AuctionDef, "PlaceBid"> = (
command,
state, // current state is passed as an argument to help make decision
{ clock }, // pick the clock from the injected infrastructure
) => {
const { bidderId, amount } = command.payload;
const now = clock.now();
if (state.status === "closed") {
// Rejection event instead of throwing an exception
return {
name: "BidRejected",
payload: { bidderId, amount, reason: "Auction is closed" },
};
}
if (now > state.endsAt) {
return {
name: "BidRejected",
payload: { bidderId, amount, reason: "Auction has ended" },
};
}
const minimumBid = state.highestBid?.amount ?? state.startingPrice;
if (amount <= minimumBid) {
return {
name: "BidRejected",
payload: {
bidderId,
amount,
reason: `Bid must exceed ${minimumBid}`,
},
};
}
return {
name: "BidPlaced",
payload: { bidderId, amount, timestamp: now },
};
};
Next, we need an evolve function, which gets executed on each emitted event to construct the next state snapshot
// evolvers/evolve-bid-placed.ts
import type { InferEvolveHandler } from "@noddde/core";
import type { AuctionDef } from "../auction";
// The InferEvolveHandler type utility helps narrow down the types of params
export const evolveBidPlaced: InferEvolveHandler<
AuctionDef,
"BidPlaced"
> = (event, state) => ({
...state,
highestBid: { bidderId: event.bidderId, amount: event.amount },
bidCount: state.bidCount + 1,
});
One last step, wiring the typed contract with the business logic we just declared into our functional Aggregate
// auction.ts
import { defineAggregate } from "@noddde/core";
import type { AuctionDef } from "../auction";
// Combine all of the decision makers and state evolvers to construct an Aggregate
export const Auction = defineAggregate<AuctionDef>({
initialState: initialAuctionState,
decide: {
CreateAuction: decideCreateAuction,
PlaceBid: decidePlaceBid,
CloseAuction: decideCloseAuction,
},
evolve: {
AuctionCreated: evolveAuctionCreated,
BidPlaced: evolveBidPlaced,
BidRejected: evolveBidRejected,
AuctionClosed: evolveAuctionClosed,
},
});
Notice what is missing here? There are no import { drizzle } statements. There are no dependency injection containers. It is entirely infrastructure-agnostic.
Step 2: Testing Without Mocks
Because our logic is pure, we don't need a database to test it. We don't need jest.mock(). noDDDe provides a Given-When-Then test harness out of the box.
import { testAggregate } from "@noddde/testing";
it("should accept a bid above the starting price", async () => {
const { events, state } = await testAggregate(Auction)
// Arrange - setup state using past events
.given({
name: "AuctionCreated" as const,
payload: { item: "Vintage Watch", startingPrice: 100, endsAt: futureDate },
})
// Act - fire a command
.when({
name: "PlaceBid",
targetAggregateId: "auction-1",
payload: { bidderId: "alice", amount: 150 },
})
.withInfrastructure(clockAt(now))
.execute();
// Expect - make assertions on the state changes, and emitted events
expect(events[0]!.name).toBe("BidPlaced");
expect(state.highestBid).toEqual({
bidderId: "alice",
amount: 150,
});
expect(state.bidCount).toBe(1);
});
This test runs in milliseconds. It is completely decoupled from your infrastructure.
Step 3: Wiring it to a Database (State-Stored)
Now we need to actually save this to a database. Let's say we want to start simple: just standard CRUD using Drizzle ORM. We only want to store the current state in a standard SQL table.
With noDDDe, you simply plug the aggregate into the engine and configure the persistence strategy:
In practice, we define our domain
import { createEngine } from "@noddde/engine";
// Aggregates constitute only the write side of a domain
// We'll cover other parts later
const auctionDomain = defineDomain<AuctionInfrastructure>({
writeModel: { aggregates: { Auction } },
readModel: { projections: { /* omitted for now */} },
processModel: { sagas: { /* omitted for now */} },
});
Then, wire our domain to actual infrastructure, noDDDe comes with a Drizzle adapter out of the box
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { createDrizzleAdapter } from "@noddde/drizzle";
// Initiate drizzle instance
const db = drizzle(new Database("app.db"));
// Pick the "state stored" persistence strategy for Aggregates
const { stateStoredPersistence } = createDrizzleAdapter(db);
const auctionRuntime = await wireDomain(auctionDomain, {
infrastructure: () => ({ clock: new SystemClock() }),
aggregates: {
persistence: () => stateStoredPersistence,
}
});
Finally we can dispatch commands to our Auction domain
// Create an Auction
await auctionRuntime.dispatchCommand({
name: "CreateAuction",
targetAggregateId: auctionId,
payload: {
item: "Vintage Guitar",
startingPrice: 500,
endsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// Alice places an initial bid
await auctionRuntime.dispatchCommand({
name: "PlaceBid",
targetAggregateId: auctionId,
payload: { bidderId: "alice", amount: 550 },
});
// Bob places a highere bid => accepted
await auctionRuntime.dispatchCommand({
name: "PlaceBid",
targetAggregateId: auctionId,
payload: { bidderId: "bob", amount: 600 },
});
// Bob places a highere bid => accepted
await auctionRuntime.dispatchCommand({
name: "PlaceBid",
targetAggregateId: auctionId,
payload: { bidderId: "bob", amount: 600 },
});
// Charlie tries to place a 3rd bid => rejected because below current highest
await auctionRuntime.dispatchCommand({
name: "PlaceBid",
targetAggregateId: auctionId,
payload: { bidderId: "charlie", amount: 580 },
});
// Bid finished, close the bid
await auctionRuntime.dispatchCommand({
name: "CloseAuction",
targetAggregateId: auctionId,
});
// Winner: bob with $600
Under the hood, the engine fetches the current state from the database, runs your pure functions, and saves the new state back. Standard CRUD, achieved with DDD rigor.
Step 4: The Pivot to Event Sourcing
Six months pass. The finance team comes to you. "We need a complete audit trail of every action ever made. We can't just store the final bid anymore."
In a traditional architecture, this is a nightmare. You have to rewrite your services, change your database schema, and fundamentally alter how you handle state.
With noDDDe, your business logic is already returning events. To switch to Event Sourcing, you touch exactly one file: the domain wiring.
import { drizzle } from "drizzle-orm/better-sqlite3";
import { createDrizzleAdapter } from "@noddde/drizzle";
// Pick the event sourced persistence strategy for Aggregates
const { eventSourcedPersistence } = createDrizzleAdapter(db);
const auctionRuntime = await wireDomain(auctionDomain, {
aggregates: {
persistence: () => eventSourcedPersistence,
}
});
That's it. You didn't touch auction.ts. You didn't touch your unit tests.
Now, when you execute a command, the engine will fetch the event stream, reduce it into the current state, run your command, and append the new events to your Event Store. You get the audit trail instantly.
Stop Fighting Your Architecture
DDD shouldn't be a painful exercise in boilerplate, and it shouldn't force you into an all-or-nothing commitment to Event Sourcing on day one. By adopting the Decider pattern, you can build software that easily adapts to the scale of your business.
noDDDe is currently in active development and I am looking for TypeScript engineers to test it, break it, and help shape the API.
📖 Read the Docs: noddde.dev
🐙 Star the Repo: github.com/dogganidhal/noddde
If you're tired of writing database mocks, clone the repo and try out the Given-When-Then tester. Let me know what you think in the comments!
Top comments (0)