
When I moved from Java to TypeScript a few years ago, most things got easier. The tooling was...
When I moved from Java to TypeScript a few years ago, most things got easier. The tooling was lighter, the iteration speed was faster, and the type system - while different - was surprisingly expressive once you learned to lean into it.
But when I needed Domain-Driven Design, I hit a wall.
In Java, DDD frameworks are mature and plentiful. Axon Framework gives you aggregates, event sourcing, and sagas out of the box. The patterns are well-established, the tooling is battle-tested, and the ecosystem assumes you'll want to model your domain properly.
In TypeScript? The options were thin.
NestJS has a CQRS module, but it requires buying into the entire NestJS ecosystem. Its DI container, its decorators, its module system. If you're already on NestJS, that's fine. But if you're working in an existing codebase, or you just want DDD primitives without adopting an opinionated application framework, it's a non-starter.
I looked at wolkenkit, which was genuinely promising; a CQRS and event sourcing framework built for Node.js. But the project stalled. I even reached out to one of the main contributors. It wasn't an option for production use.
Beyond those, the landscape was scattered: thin libraries that gave you an event store client but no aggregate abstraction, hand-rolled patterns copied between projects, or blog posts explaining the theory without shipping usable code.
Coming from the Java world, where I could model aggregates, wire up event sourcing, define projections, and coordinate sagas with well-supported tooling, the gap in TypeScript felt unnecessary. The language had everything it needed: discriminated unions, mapped types, type inference, literal types. The foundations were there. Nobody had built the framework.
So I built one. I called it noDDDe.
The first decision was the most important: no classes.
Most DDD frameworks model aggregates as classes that extend a base AggregateRoot. You decorate methods with @CommandHandler, mutate state through this.apply(), and wire everything together with a DI container. This works, but it fights TypeScript's strengths rather than leveraging them.
noDDDe uses the Decider pattern instead. An aggregate is defined by three things:
const BankAccount = defineAggregate<BankAccountDef>({
initialState: { balance: 0 },
commands: {
Deposit: (command, state) => ({
name: "DepositMade",
payload: { amount: command.payload.amount },
}),
},
apply: {
DepositMade: (payload, state) => ({
balance: state.balance + payload.amount,
}),
},
});
No base class. No decorators. No this. The defineAggregate function is actually an identity function, it exists only so TypeScript can infer the types. Zero runtime overhead.
The thing I missed most from Java wasn't the frameworks themselves, it was the confidence that the wiring was correct. TypeScript can give you that confidence, but only if the framework is designed for it.
In noDDDe, you declare a types bundle:
type BankAccountDef = {
state: BankAccountState;
commands: BankingCommand;
events: BankingEvent;
infrastructure: { clock: Clock };
};
From that single type, TypeScript infers everything: what commands each handler receives, what events it can return, what the apply handler's payload looks like, and what infrastructure is available. If it compiles, the wiring is correct.
Commands and events are built with mapped types:
type BankingCommand = DefineCommands<{
Deposit: { amount: number };
Withdraw: { amount: number };
}>;
type BankingEvent = DefineEvents<{
DepositMade: { amount: number };
WithdrawalMade: { amount: number };
}>;
One declaration produces the discriminated union, the payload types, and the handler signatures. No enums, no manual union types, no keeping three files in sync.
Because command handlers are functions and apply handlers are pure functions, testing is trivial:
const result = await testAggregate(BankAccount)
.given(
{ name: "AccountCreated", payload: { id: "acc-1" } },
{ name: "DepositMade", payload: { amount: 1000 } },
)
.when({
name: "Withdraw",
targetAggregateId: "acc-1",
payload: { amount: 200 },
})
.execute();
expect(result.events[0].name).toBe("WithdrawalMade");
expect(result.state.balance).toBe(800);
No framework bootstrap. No DI container setup. No mocking an event bus. Given events, when command, then events and state. That's it.
noDDDe isn't just the aggregate pattern. It's the full stack of DDD/CQRS/ES primitives:
The same aggregate definition works with event sourcing or state storage. Persistence is a configuration choice, not an architecture decision.
noDDDe is new. The packages are published on npm (@noddde/core, @noddde/engine, @noddde/testing, plus ORM adapters), the documentation is comprehensive, and there are several sample projects covering different ORMs and domain patterns.
The API is stabilizing, but pre-1.0. I'm looking for feedback from TypeScript developers who've built (or tried to build) event-sourced or DDD-based systems. What's missing? What's awkward? What would make this useful for your next project?
GitHub: github.com/dogganidhal/noddde
Docs: noddde.dev
npm: yarn add @noddde/core @noddde/engine
Open an issue, roast the API, tell me what's missing.
aiMost of us have seen a coding agent fail to complete a task we know it can do. We just don't...
googlecloudWhen building Generative AI applications, developers often encounter a massive bottleneck: sequential...
discussI’ve been thinking about sharing some electronic circuit posts on Dev.to — small circuits, DIY...
agentsWhat nobody tells you about exporting your multi-agent prototype to a local workspace. Every...
agenticarchitectAutonomous agents are genuinely good at answering messy business questions. Give one an LLM and a set...
aiPR volume went up, ticket quality didn't, and the gap got filled with LLMs on both sides of the review: bots reviewing, bots replying, bots occasionally arguing with bots about priorities that only existed in a teammate's head. Our CEO named the actual problem, and it's bigger than code review.
Workflows from the Neura Market marketplace related to this Stable Diffusion resource