This Gist serves as an initial implementation of a crash course designed for building testable Node.js Apps.
- The Hexagonal Premise: Inversion of Control and the sanctity of the Domain.
- The Domain Model: Distinguishing Entities from Values.
- Aggregates: Defining consistency boundaries and the Aggregate Root.
- Domain Services: Handling logic that belongs to no single object.
- Ports and Adapters: Defining interfaces (Ports) and technical implementations (Adapters).
- Repository Pattern: Decoupling the Domain from the Persistence layer.
- Test-Driven Development (TDD): Testing the "pure" domain without side effects.
In modern enterprise Node.js development, the greatest threat to project longevity is entanglement. When your business logic is married to an ORM or a specific web framework, the cost of change scales exponentially.
Hexagonal Architecture (or Ports and Adapters) addresses this by placing the "Domain" at the center.
- The Core: Contains the business rules (Entities, Values, Services). It has no dependencies on external libraries.
- The Ports: Interfaces that define how the outside world interacts with the core (Inbound) and how the core interacts with the world (Outbound).
- The Adapters: Concrete implementations (Express.js, PostgreSQL, Stripe SDK) that plug into these ports.
In this module, we define the Order aggregate. We utilize Values for attributes that are defined by their properties rather than a unique identity.
- The
src/domain/orders/valuesfolder: Stores immutable objects likeMoneyandAddress. - The
src/domain/orders/repositoriesfolder: Contains the Port (interface) for the Order Repository. - The
datafolder: This is an infrastructure concern (e.g.,/src/infrastructure/database/data) used for seed files or local JSON persistence.
The Money class represents a Value. It is immutable; any operation (like addition) returns a new instance. It ensures that we never perform arithmetic on mismatched currencies.
'use strict';
class Money {
constructor(amount, currency) {
if (typeof amount !== 'number') throw new Error('Amount must be a number');
this.amount = amount;
this.currency = currency;
Object.freeze(this); // Ensure immutability
}
equals(other) {
return this.amount === other.amount && this.currency === other.currency;
}
add(other) {
if (this.currency !== other.currency) {
throw new Error('Currency mismatch');
}
return new Money(this.amount + other.amount, this.currency);
}
}
module.exports = Money;
'use strict';
const Money = require('./values/Money');
class Order {
#id;
#status;
#items;
constructor(id, customerId) {
this.#id = id;
this.customerId = customerId;
this.#status = 'CREATED';
this.#items = [];
}
addItem(productId, price) {
if (this.#status !== 'CREATED') throw new Error('Cannot modify confirmed order');
this.#items.push({ productId, price });
}
confirm() {
if (this.#items.length === 0) throw new Error('Cannot confirm empty order');
this.#status = 'CONFIRMED';
}
cancel() {
this.#status = 'CANCELLED';
}
get total() {
return this.#items.reduce(
(sum, item) => sum.add(item.price),
new Money(0, 'USD')
);
}
toJSON() {
return { id: this.#id, status: this.#status, total: this.total };
}
}
module.exports = Order;
The Delivery domain focuses on the transition from an Order to a physical movement. Here we introduce the AssignmentService, a Domain Service that coordinates between a Delivery and a Driver.
'use strict';
class Delivery {
constructor(id, orderId, location) {
this.id = id;
this.orderId = orderId;
this.location = location;
this.driverId = null;
this.status = 'PENDING';
}
assignDriver(driverId) {
this.driverId = driverId;
this.status = 'ASSIGNED';
}
}
module.exports = Delivery;
TODO