What problems does @xndrjs/domain actually solve?
Documentation and previous blog posts explain how xndrjs works. A fair follow-up question is narrower and more urgent:
Yes, fine, but what concrete problems does
@xndrjs/domainsolve in day-to-day code?
Below are some of the reasons why I decided to work on this project.
1. Static types are not enough at external boundaries
Section titled “1. Static types are not enough at external boundaries”When data comes from outside your process — REST responses, query strings, forms… — you are not looking at “a UserDTO”. You are literally looking into the unknown.
You still want to map that payload into something your application understands. But what is the most common way to do this?
return data as UserDto
The painful failure here is not caught by static typing. It is a silent drift:
- the mapping runs
- corrupted or partial data spreads
- you only notice deep inside business logic, or in the UI, when assumptions explode
Runtime validation plus a small mapping step gives you fail-fast boundaries. If the contract is wrong, you learn immediately at the edge, not three layers later. That is disproportionately valuable in growing systems, where “bad data” is expensive precisely because it is ordinary.
2. Creating domain values without drowning in ceremony
Section titled “2. Creating domain values without drowning in ceremony”Rich domain models built from classes are elegant. But they also tend to accumulate construction friction: nested constructors, factories for value objects, repetitive wiring just to build a valid aggregate.
xndrjs leans into a different ergonomics:
- write the payload as a normal object literal
- pass it through a single creation path that validates
Nested structure does not force you to manually instantiate every sub-shape or primitive wrapper if the shape’s validator already composes its parts. The parent validation orchestrates the children. That keeps call sites readable and reduces “domain boilerplate theater.”
Compare the call site for the same aggregate in a stylized rich OOP model (nested value objects) versus domain.shape as in Primitives and shapes.
Rich OOP-style construction: every nested piece is its own type and constructor. For instance:
const user = new User( UserId.from("u_1"), Email.from("alice@example.com"), new Address(Street.from("1 Main St"), City.from("Paris")));Same idea with xndrjs: you define validation once (here with Zod via @xndrjs/domain-zod).
So the shapes and primitives for our example would be something like this:
import { domain, zodFromKit, zodToValidator } from "@xndrjs/domain-zod";import { z } from "zod";
const EmailKit = domain.primitive("Email", zodToValidator(z.email()));
const AddressKit = domain.shape( "Address", zodToValidator( z.object({ street: z.string().min(1), city: z.string().min(1), }) ));
const User = domain.shape( "User", zodToValidator( z.object({ id: z.string().min(1), email: zodFromKit(EmailKit), // compose existing Email Kit address: zodFromKit(AddressKit), // compose existing Address Kit }) ));Then, you create a user simply as a plain object at the boundary. No nested constructor gymnastics required.
const user = User.create({ id: "u_1", email: "alice@example.com", address: { street: "1 Main St", city: "Paris" },});Now look back at the OOP version:
const user = new User( UserId.from("u_1"), Email.from("alice@example.com"), new Address(Street.from("1 Main St"), City.from("Paris")));Did you notice the difference at the call site? xndrjs does not require you to import UserId, Email, Address, Street, and City every time you need to create a User. The composition is defined once, inside the domain, at the shape-definition level. Call sites can stay focused on the plain payload they received.
3. Moving data across layers without turning the codebase into Mapper City
Section titled “3. Moving data across layers without turning the codebase into Mapper City”The representation xndrjs encourages is data-centric: validated values without behavior welded onto instances. That matters when you cross boundaries inside your own application:
- infrastructure (HTTP, persistence)
- orchestration / application use cases
- domain rules
- presentation
Plain, validated data travels cleanly. You still introduce mappers when semantics genuinely diverge, but you are less tempted to create 1:1 mapping layers whose only job is type gymnastics.
On the frontend specifically, stable referential identity still matters for reactive frameworks. Plain validated objects play well there. Unlike patterns that thread UI concepts deep into the model - say, reactive wrappers everywhere - @xndrjs/domain does not depend on view idioms. It composes nicely with modern FE when you want it to, and stays independent when you need it to.
4. Shape transformations when one underlying fact wears different hats
Section titled “4. Shape transformations when one underlying fact wears different hats”Complex domains often need multiple typed views of the same underlying information, for example:
- a generic representation for graph walks, dependency resolution, or path algorithms
- a stricter representation for a specific workflow or screen
Teams often bridge those views with object spreads and casts. That works until it does not: the rules live in scattered helpers, and nobody has a single place to read or trust them. Projection is the alternative: one explicit step that moves from one trusted shape to another and runs validation again. The transition stays visible in the code instead of implicit in utility functions.
5. Knowing where invariants and guarantees actually live
Section titled “5. Knowing where invariants and guarantees actually live”Anemic frontend models frequently scatter validation and sanity checks across components, hooks, and services. The result is hard to reason about:
- duplicated rules that will drift apart
- redundant checks nobody really trusts
- “fix it in the UI” patches that never migrate backward
xndrjs nudges you toward a clearer split:
- baseline structural and local rules live with the shape’s validator definition
- stronger or cross-cutting guarantees surface as explicit proofs where they belong
That helps newcomers answer a boring but critical question quickly: “where do validation rules and other checks live?” The answer has a default home instead of a scavenger hunt.
6. Preventing outside code from mutating the domain directly
Section titled “6. Preventing outside code from mutating the domain directly”If every part of the system can mutate domain objects on its own, shortcuts become the default path. A component changes a field. A service patches a nested property. A utility “just fixes” a value before sending it somewhere else.
The problem is not only mutation itself. The deeper problem is that the domain stops saying which operations are actually available. The codebase loses a shared vocabulary for what can happen to a value, and call sites start carrying hidden responsibility for keeping objects valid. This is the common failure mode of anemic domain models: data is easy to pass around, but the allowed operations are no longer visible in the model.
This responsibility does not scale.
We cannot realistically expect every caller that mutates a domain object to remember to re-run validation, preserve invariants, and choose the same semantics as the rest of the application. Keeping that kind of alignment across scattered call sites is effectively impossible: sooner or later it creates bugs, duplicated rules, and a codebase that becomes harder and harder to maintain.
This is why xndrjs values are treated as immutable: they are marked as readonly at type level and frozen at runtime. Changes go through explicit domain operations instead.
Capabilities were born from that need. They expose named operations while keeping instances data-only. When a capability patches a value, the transition goes back through the attached shape validator, producing a new trusted value instead of mutating the old one in place.
They are also designed to depend on an arbitrarily small contract. A capability should describe only the fields it really needs, not the full shape it happens to be attached to. That keeps responsibilities limited, makes capabilities easier to compose, and lets the same operation set apply to different shapes without redefining it for every single representation.
At the same time, capabilities are not loose utilities that can be applied to any value with matching fields. The attach step binds a capability set to a particular shape or primitive kit and returns a capability kit with only those custom methods. Construction and guards stay on the schema kit (UserShape.create, MoneyPrimitive.is, …). That makes availability explicit: you can see which operations belong to which representation, and each transition still reuses the validator of the kit it is attached to (patch on shapes, create in the factory context on primitives).
That gives you a useful boundary:
- outside code can read domain data and pass it around
- domain operations decide how that data may change
- every change has a named place in the model and a validation step behind it
The plus is a combination of small things that reinforce each other: immutable values, explicit operations, small reusable capability contracts, clear attachment to concrete shapes, and validation after every sanctioned transition.
7. A well-defined domain pays down complexity elsewhere
Section titled “7. A well-defined domain pays down complexity elsewhere”Investing in the domain layer can feel like an extra step, especially when shipping pressure is high. The return is subtle but structural: the rest of the application stops working at the wrong abstraction level.
Infrastructure stops guessing. Application workflows stop improvising parsing. UI stops compensating for missing guarantees. You still integrate frameworks and libraries; you simply avoid letting accidental coupling rewrite your mental model every week.
Final words
Section titled “Final words”@xndrjs/domain is not “types, but louder”. It is a toolkit for trusted data: where it enters, how it is built, how it moves, how it morphs, and where its promises are documented in code.
If your pain points match the list above (boundary rot, constructor fatigue, mapper sprawl, multi-shape reasoning, scattered validation, uncontrolled mutation, or leaky abstractions) the concrete payoff is easier to justify than another abstraction for its own sake.