In my previous post, I laid down the basic entities and value objects to model the chemical domain. But there was a glaring flaw in the initial MVP: The Atom entity was too permissive.
The problem: Playing God
Previously, the domain allowed you to instantiate an Atom with any string as its chemical element. You could play god and create a new 'Hydroxygen' atom without the system complaining.
Obviously, in chemistry, the periodic table is a strictly closed set. The code needs to reflect that physical reality.
The solution: A Closed Registry
My solution was straightforward but rigorous. I created a strict ChemicalElement value object backed by a closed registry of valid elements. The factory method of this VO only accepts a valid chemical symbol, otherwise, it rejects the creation.
To make this completely type-safe, I used a TypeScript generic trick to infer the literal types and guarantee that the object key always matches the element's symbol.
Here's the code:
export interface ElementData {
symbol: string;
name: string;
atomicNumber: number;
atomicMass: number;
commonValencies: number[];
}
// This helper function enforces that the key of the element exactly matches its symbol property
const createElementsMap = <T extends Record<string, ElementData>>(elements: {
[K in keyof T]: T[K] & { symbol: K };
}) => elements;
export const ELEMENTS = createElementsMap({
H: {
symbol: "H",
name: "Hydrogen",
atomicNumber: 1,
atomicMass: 1.008,
commonValencies: [1],
},
C: {
symbol: "C",
name: "Carbon",
atomicNumber: 6,
atomicMass: 12.011,
commonValencies: [4],
}
// ...
});
export type ElementSymbol = keyof typeof ELEMENTS;
export type ChemicalElementProps = Readonly<{
symbol: string;
name: string;
atomicNumber: number;
atomicMass: number;
commonValencies: number[];
}>;
export class ChemicalElement extends ValueObject<ChemicalElementProps> {
private constructor(props: ChemicalElementProps) {
super(props);
}
public static create(symbol: string): Result<ChemicalElement, Error> {
if (!Object.hasOwn(ELEMENTS, symbol)) {
return err(new Error(`Invalid element symbol: ${symbol}`));
}
const elementData = ELEMENTS[symbol as ElementSymbol];
return ok(
new ChemicalElement({
symbol: elementData.symbol,
name: elementData.name,
atomicNumber: elementData.atomicNumber,
atomicMass: elementData.atomicMass,
commonValencies: [...elementData.commonValencies],
}),
);
}
}
The Error Flow: Result vs. Exceptions
If you noticed the Result return type above, that's because I'm using a library called neverthrow. It brings the Result monad pattern to TypeScript, wrapping a function's outcome in an object that contains either the expected value or an explicit error. This forces the consumer to handle the error, completely eliminating the hell of unhandled exceptions crashing the app.
However, I still use raw throw statements but strictly for Invariant Violations, situations that imply a catastrophic failure in the system's memory or logic, not just a business rule rejection.
To see this philosophy in action, look at how the Molecule aggregate root handles the deletion of an atom:
public removeAtom(atomId: EntityId): Result<void, Error> {
const atom = this._atoms.get(atomId);
// Expected Domain Error: The user tried to delete an atom that isn't there.
if (!atom) {
return err(new Error("Atom not found in molecule"));
}
const bondsToRemove = atom.bonds;
for (const bond of bondsToRemove) {
const otherAtomId = bond.atomIds.find((id) => id !== atomId);
if (!otherAtomId) {
// INVARIANT VIOLATION: A bond exists but doesn't connect to another atom.
// The graph is corrupted. We panic and throw.
throw new Error("Invalid bond: does not connect to another atom");
}
const otherAtom = this._atoms.get(otherAtomId);
if (!otherAtom) {
// INVARIANT VIOLATION: The bond points to a ghost atom.
throw new Error("Invalid bond: other atom not found in molecule");
}
const removeResult = otherAtom.removeBond(bond);
if (removeResult.isErr()) {
return err(removeResult.error);
}
}
this._atoms.delete(atomId);
return ok();
}
By distinguishing between return err() (business logic failures) and throw new Error() (corrupted state), the domain remains predictable, safe, and incredibly easy to debug.
In upcoming posts, I'll dive into how I connected this pure chemical domain to a visual Canvas, and the vector math required to select chemical bonds on the screen.
Top comments (0)