When we started building the Catalyst PSA platform, we knew we were in for the long haul. Professional Services Automation is complex—projects, time tracking, resource management, invoicing, expense tracking—all interconnected with subtle business rules. After 27 years of building software, I knew that without a solid domain model, we'd end up with an unmaintainable mess.
Domain-Driven Design (DDD) gave us the framework to tackle this complexity. Three years later our investment in DDD has paid enormous dividends. Here's what we learned.
What is Domain-Driven Design?
DDD, introduced by Eric Evans, is a software development approach that focuses on deeply understanding the business domain and expressing that understanding in code. It's not just about patterns—it's about creating a shared language between developers and domain experts, and modeling that language in your software.
The core concepts include:
- Ubiquitous Language: A shared vocabulary between technical and business teams
- Bounded Contexts: Clear boundaries around related concepts
- Entities and Value Objects: Domain objects with identity vs. without
- Aggregates: Consistency boundaries around related entities
- Domain Events: Things that happened in the domain
- Repositories: Abstraction for retrieving domain objects
Building a Ubiquitous Language
Our first step was establishing a common language with our PSA domain experts. This sounds simple but was transformative.
Before: Developer Language vs. Business Language
Developers said: "We need a table for work items with a foreign key to users and a status flag."
Business experts said: "When consultants log billable hours against client projects, we need to track whether those hours have been approved by the project manager and invoiced to the client."
These describe the same thing, but the disconnect was causing confusion.
After: Ubiquitous Language
We settled on terms like:
- Time Entry (not "work item" or "timesheet record")
- Billable Hours (not "invoiceable time")
- Project Assignment (not "user project mapping")
- Approval Workflow (not "status flag")
Then we used these exact terms in our code:
export class TimeEntry extends Entity {
constructor(
id: string,
private consultantId: string,
private projectAssignment: ProjectAssignment,
private billableHours: BillableHours,
private approvalWorkflow: ApprovalWorkflow
) {
super(id);
}
submitForApproval(): void {
this.approvalWorkflow.submit();
this.addDomainEvent(new TimeEntrySubmittedEvent(this.id));
}
approveByProjectManager(managerId: string): void {
if (!this.projectAssignment.isManager(managerId)) {
throw new UnauthorizedApprovalError();
}
this.approvalWorkflow.approve();
this.addDomainEvent(new TimeEntryApprovedEvent(this.id));
}
}When business experts reviewed this code, they understood it immediately. When developers talked to clients, they used the same terminology. This alignment eliminated countless misunderstandings.
Identifying Bounded Contexts
The biggest challenge in DDD is finding the right boundaries for your contexts. A term like "Project" might mean different things in different parts of your system.
Our Bounded Contexts in Catalyst PSA
We identified seven major bounded contexts:
- Project Management: Projects, tasks, milestones, dependencies
- Time Tracking: Time entries, approvals, corrections
- Resource Management: Consultant availability, skills, assignments
- Billing & Invoicing: Invoice generation, payment tracking
- Expense Management: Expense claims, approvals, reimbursements
- Client Management: Client information, contracts, contacts
- Reporting & Analytics: Cross-context data aggregation
Each context had its own models. A "Project" in the Project Management context had tasks, dependencies, and Gantt chart data. A "Project" in the Billing context was just an invoice line item grouping. They shared an ID but were otherwise independent.
Bounded Context Implementation
We enforced context boundaries in code:
src/
project-management/
domain/
application/
infrastructure/
time-tracking/
domain/
application/
infrastructure/
billing/
domain/
application/
infrastructure/
Contexts could only communicate through:
- Domain Events: Async, eventual consistency
- APIs: Synchronous queries for read-only data
- Shared Kernel: Common value objects (Money, Date ranges)
We had linting rules to prevent direct imports across contexts:
// ❌ Not allowed
import { Project } from '../../project-management/domain/project';
// ✅ Allowed - consuming events
@EventsHandler(ProjectCreatedEvent)
export class SyncProjectToBilling implements IEventHandler {
// ...
}
// ✅ Allowed - querying through API
const project = await this.projectQueryService.getById(projectId);Entities vs. Value Objects
Understanding the difference between entities and value objects was crucial for our domain model.
Entities: Objects with Identity
Entities have a unique identity that persists over time. Even if all their attributes change, they're still the same entity.
export class Consultant extends Entity {
constructor(
id: string,
private email: Email, // Value object
private name: PersonName, // Value object
private hourlyRate: Money, // Value object
private skills: Skill[] // Value objects
) {
super(id);
}
updateHourlyRate(newRate: Money): void {
this.hourlyRate = newRate;
this.addDomainEvent(new ConsultantRateChangedEvent(this.id, newRate));
}
}A consultant with ID "cons-123" is always that consultant, even if they change their name, email, and rate.
Value Objects: Objects without Identity
Value objects are defined by their attributes. Two value objects with the same attributes are interchangeable.
export class Money {
constructor(
private readonly amount: number,
private readonly currency: Currency
) {
if (amount < 0) {
throw new InvalidMoneyError('Amount cannot be negative');
}
}
add(other: Money): Money {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchError();
}
return new Money(this.amount + other.amount, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency.equals(other.currency);
}
}Money with value ($100, USD) is identical to any other ($100, USD). We don't care which specific instance we have.
Value objects are immutable. Operations return new instances rather than modifying existing ones. This made our code much easier to reason about and prevented subtle bugs.
Aggregates: Consistency Boundaries
Aggregates were the hardest DDD concept to get right. An aggregate is a cluster of entities and value objects that are treated as a single unit for data changes.
The Problem: Transactional Boundaries
Initially, we made everything transactional. Updating a project would update all its tasks, all time entries on those tasks, update resource allocations, and recalculate budgets—all in one transaction. This was slow and caused deadlocks.
The Solution: Smaller Aggregates
We learned to keep aggregates small and use eventual consistency between them:
// Project aggregate - small and focused
export class Project extends AggregateRoot {
constructor(
id: string,
private name: string,
private clientId: string, // Reference to another aggregate
private status: ProjectStatus,
private budget: Money
) {
super(id);
}
complete(): void {
if (this.status === ProjectStatus.COMPLETED) {
throw new ProjectAlreadyCompletedError();
}
this.status = ProjectStatus.COMPLETED;
this.addDomainEvent(new ProjectCompletedEvent(this.id));
}
}
// Task is a separate aggregate, not part of Project
export class Task extends AggregateRoot {
constructor(
id: string,
private projectId: string, // Reference to Project aggregate
private name: string,
private assigneeId: string
) {
super(id);
}
}When a project completes, we emit an event. An event handler updates related tasks asynchronously:
@EventsHandler(ProjectCompletedEvent)
export class CompleteProjectTasksHandler {
async handle(event: ProjectCompletedEvent): Promise<void> {
const tasks = await this.taskRepository.findByProjectId(event.projectId);
for (const task of tasks) {
if (!task.isCompleted) {
task.complete();
await this.taskRepository.save(task);
}
}
}
}This kept our aggregates small and our transactions fast.
Domain Events: The Glue
Domain events were the secret to connecting our bounded contexts and aggregates without tight coupling.
export class TimeEntryApprovedEvent {
constructor(
public readonly timeEntryId: string,
public readonly consultantId: string,
public readonly projectId: string,
public readonly billableHours: number,
public readonly billableAmount: Money,
public readonly occurredAt: Date
) {}
}Multiple contexts could react to the same event:
// In Billing context - create invoice line item
@EventsHandler(TimeEntryApprovedEvent)
export class CreateInvoiceLineItemHandler {
async handle(event: TimeEntryApprovedEvent): Promise<void> {
// Add to invoice
}
}
// In Reporting context - update analytics
@EventsHandler(TimeEntryApprovedEvent)
export class UpdateConsultantUtilizationHandler {
async handle(event: TimeEntryApprovedEvent): Promise<void> {
// Update utilization metrics
}
}
// In Time Tracking context - update read models
@EventsHandler(TimeEntryApprovedEvent)
export class UpdateTimeEntryListHandler {
async handle(event: TimeEntryApprovedEvent): Promise<void> {
// Update denormalized query models
}
}Events made our system extensible. When we added expense management, we subscribed to relevant events from other contexts without modifying their code.
Repositories: Persistence Abstraction
Repositories hide persistence details from the domain:
export interface ProjectRepository {
findById(id: string): Promise<Project | null>;
save(project: Project): Promise<void>;
// Note: No update() method - we load, modify, and save
}The key insight: repositories work with full aggregates, not individual fields. You load an aggregate, call domain methods, and save it back. This keeps domain logic in the domain.
Lessons Learned After 320K+ Lines
Lesson 1: Start with Ubiquitous Language, Not Patterns
We initially focused on implementing repositories, aggregates, and entities. This was backwards. We should have started by building a shared language with domain experts. The patterns follow naturally once you understand the domain.
Lesson 2: Bounded Contexts Prevent the Big Ball of Mud
As the codebase grew, bounded contexts saved us. New developers could understand one context without understanding the entire system. Contexts evolved independently—we rewrote the reporting context twice without touching other contexts.
Lesson 3: Keep Aggregates Small
Our first aggregates were huge—a Project aggregate containing all tasks, all time entries, all assignments. Loading a project meant loading hundreds of related objects. We learned to make aggregates as small as possible while maintaining invariants.
Lesson 4: Eventual Consistency is Okay
Business users understood that a project dashboard might take a second to update after they approve time entries. Eventual consistency let us keep aggregates small and the system fast.
Lesson 5: DDD is About Continuous Learning
Our domain model evolved constantly as we learned more about PSA. We refactored entities into value objects, split contexts, merged contexts. DDD gave us the tools to evolve the model safely.
When to Use DDD
DDD shines for:
- Complex business domains with subtle rules
- Long-lived applications that will evolve
- Large codebases with multiple teams
- Applications where business logic is the complexity (not technical challenges)
Skip DDD for:
- Simple CRUD apps
- Technical problems (image processing, data pipelines)
- MVPs where speed matters more than design
- Applications with no complex business rules
Conclusion
Domain-Driven Design was essential for building and maintaining an enterprise SaaS platform. The ubiquitous language aligned our team, bounded contexts kept complexity manageable, and aggregates gave us the right consistency boundaries.
After 27 years of building software, I've learned that the hardest part isn't the technology—it's understanding the business domain and expressing that understanding in code. DDD provides the tools and patterns to do exactly that.
If you're building complex business software that needs to evolve over years, invest in DDD. It will pay dividends as your codebase grows.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.