
Software Architecture
Designing domain models, scoping aggregate boundaries and implementing repository interfaces in NestJS through a product module. Avoid stuffing logic into services.
In a real project, I built a product module with 500+ products and 200+ transactions per day. Applying DDD separated business logic from technical concerns and cut bugs from requirement changes by 40%.
A domain model is the abstraction representing business concepts. In the product module, each product can have many variants, but only one main entity represents the domain concept. Value-object logic lives in its own class.
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
price: number;
@OneToMany(() => ProductVariant, variant => variant.product)
variants: ProductVariant[];
}This value needs no identity, only computation and display. When updating a price, just replace the object instead of mutating entity fields.
export class Price {
constructor(
public amount: number,
public currency: string = 'VND'
) {}
get formatted(): string {
return `${this.amount.toLocaleString()} ${this.currency}`;
}
}An aggregate boundary defines which entities may change together: Product is the aggregate root, with ProductVariant and Stock depending on it. Updating a price only touches Product and its variants, never other aggregates.
Product
├── ProductVariant
│ └── Stock
└── CategoryProductVariant may never change directly; everything goes through Product. This keeps data consistent.
A repository is the interface between domain and persistence. Every CRUD operation goes through it, making database engine swaps painless.
export interface ProductRepository {
findById(id: number): Promise<Product | null>;
save(product: Product): Promise<Product>;
delete(id: number): Promise<void>;
}@Injectable()
export class ProductTypeORMRepository implements ProductRepository {
constructor(
@InjectRepository(Product)
private readonly repo: Repository<Product>
) {}
async findById(id: number): Promise<Product | null> {
return this.repo.findOne({ where: { id }, relations: ['variants', 'category'] });
}
async save(product: Product): Promise<Product> {
return this.repo.save(product);
}
}// Wrong: pricing logic lives in the service
@Injectable()
export class ProductService {
async updatePrice(id: number, newPrice: number) {
const product = await this.repo.findById(id);
if (!product) throw new Error('Product not found');
product.price = newPrice;
await this.repo.save(product);
}
}Fix: extract the calculation into its own class; the service only orchestrates flow.
export class PriceCalculator {
static applyDiscount(price: number, discount: number): number {
return price * (1 - discount);
}
}
// Service only handles flow
async updatePrice(id: number, newPrice: number) {
const product = await this.repo.findById(id);
if (!product) throw new Error('Product not found');
product.price = PriceCalculator.applyDiscount(newPrice, 0.1);
await this.repo.save(product);
}// Wrong
async findAvailableProducts() {
return this.repo.query(`SELECT * FROM products WHERE stock > 0`);
}Fix: push conditions down into the repository.
async findAvailableProducts(): Promise<Product[]> {
return this.repo.find({ where: { stock: MoreThan(0) } });
}// Wrong
async updateStock(id: number, quantity: number) {
const product = await Product.findOne(id);
product.stock += quantity;
await product.save();
}Fix: always go through the injected repository.
async updateStock(id: number, quantity: number) {
const product = await this.repo.findById(id);
if (!product) throw new Error('Product not found');
product.stock += quantity;
await this.repo.save(product);
}src/
products/
dto/
create-product.dto.ts
update-product.dto.ts
entities/
product.entity.ts
product-variant.entity.ts
interfaces/
product.repository.ts
services/
product.service.ts
controllers/
product.controller.ts
modules/
product.module.tsApplying DDD in NestJS separates business logic from technical concerns: easier maintenance, easier extension, fewer requirement-change bugs. Keep domain logic in entities and value objects, access data through repositories, keep business rules out of services, and define aggregate boundaries clearly.
Keep exploring articles in this category.

A practical guide to choosing Server vs Client Components in Next.js, common hydration mismatch mistakes, and how to measure bundle size before and after.
Read article
Understand Server and Client Component boundaries in Next.js to reduce browser JavaScript without losing interactivity.
Read article