Home Blog Page 31

Monorepo Setups with ESLint and Prettier (for TypeScript Projects)

0
typscript course
typscript course

Table of Contents

  • What is a Monorepo?
  • Why ESLint and Prettier Setup Needs Special Attention in Monorepos
  • Common Structure of a TypeScript Monorepo
  • Centralized ESLint and Prettier Setup
  • Per-Package Overrides (When Needed)
  • Step-by-Step: Setting up ESLint and Prettier in a Monorepo
  • Handling Different TypeScript Configs
  • Best Practices
  • Conclusion

What is a Monorepo?

A monorepo is a single repository that stores code for multiple packages or projects.
Instead of separate repos, all related services, libraries, apps live together.

Example:

  • apps/ → Frontend, Backend apps
  • packages/ → Shared libraries, utilities

Popular tools: Nx, Turborepo, Lerna, or even plain npm/yarn workspaces.


Why ESLint and Prettier Setup Needs Special Attention in Monorepos

  • Different packages might have different settings (e.g., Node.js backend vs React frontend).
  • TypeScript configs (tsconfig.json) may differ between packages.
  • We want shared rules but flexibility to override locally if needed.
  • We want fast linting/formatting without unnecessary scanning.

Goal: Centralized config with optional per-package customization.


Common Structure of a TypeScript Monorepo

Example:

/monorepo-root
/apps
/backend
/frontend
/packages
/utils
/ui-components
package.json
tsconfig.base.json
.eslintrc.js
.prettierrc

Each subfolder (apps/, packages/) might be its own mini-project.


Centralized ESLint and Prettier Setup

Instead of copying .eslintrc.js and .prettierrc everywhere,
put them at the root and let packages inherit them.


Per-Package Overrides (When Needed)

Sometimes a backend might need Node.js-specific linting,
or frontend might need React-specific linting.

In that case:

  • Root config remains default.
  • Specific sub-projects override only small parts.

Step-by-Step: Setting up ESLint and Prettier in a Monorepo

1. Install ESLint and Prettier at Root

npm install --save-dev eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier eslint-plugin-prettier

If using React, also install:

npm install --save-dev eslint-plugin-react eslint-plugin-react-hooks

2. Root .eslintrc.js Configuration

module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['./tsconfig.base.json'], // important
tsconfigRootDir: __dirname,
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'prettier'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
rules: {
'prettier/prettier': 'error',
},
ignorePatterns: ['node_modules/', 'dist/', 'build/'],
};

3. Root .prettierrc

{
"semi": true,
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"trailingComma": "all",
"arrowParens": "always"
}

Add a .prettierignore:

node_modules
dist
build
coverage

4. TypeScript Configuration (tsconfig.base.json)

Example:

{
"compilerOptions": {
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}

Each sub-project will extend this.

Example in apps/frontend/tsconfig.json:

{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}

5. If Needed: Specific ESLint Overrides per Package

Inside apps/frontend/.eslintrc.js:

module.exports = {
extends: ['../../.eslintrc.js', 'plugin:react/recommended'],
settings: {
react: {
version: 'detect',
},
},
};

Inside apps/backend/.eslintrc.js (for Node.js):

module.exports = {
extends: ['../../.eslintrc.js'],
env: {
node: true,
},
};

6. Add Workspace Lint and Format Scripts

Inside root package.json:

{
"scripts": {
"lint": "eslint '**/*.{ts,tsx,js,jsx}' --fix",
"format": "prettier --write '**/*.{ts,tsx,js,jsx,json,md,yml,yaml}'"
}
}

Or better:
Target only packages you want:

eslint 'apps/**/*.{ts,tsx,js,jsx}' 'packages/**/*.{ts,tsx,js,jsx}' --fix
prettier --write 'apps/**/*.{ts,tsx,js,jsx,json,md}' 'packages/**/*.{ts,tsx,js,jsx,json,md}'

Handling Different TypeScript Configs

If you get this ESLint error:

Parsing error: "parserOptions.project" has been set for @typescript-eslint/parser

It’s because each sub-project might need its own tsconfig.json.

Solutions:

  • Configure parserOptions.project per package inside .eslintrc.js
  • Or loosen parsing rules at root if needed (createDefaultProgram: true) — not recommended for strict setups.

Best Practices

  • Always put shared config at root.
  • Use project-specific .eslintrc.js only when necessary (e.g., React vs Node).
  • Ignore build folders (dist/, build/) properly.
  • Use VSCode extensions like ESLint, Prettier, and configure formatOnSave.
  • Automate via Husky + lint-staged: run linting/formatting only on changed files before commit.

Example:

npm install --save-dev husky lint-staged

Add to package.json:

"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"**/*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
]
}

Conclusion

Setting up ESLint and Prettier in a monorepo properly ensures:

  • Code quality is consistent across multiple apps/packages.
  • Developers can focus more on building rather than formatting.
  • Scaling your monorepo becomes much easier.

Linting and Formatting: ESLint + Prettier for TypeScript

0
typscript course
typscript course

Table of Contents

  • Introduction
  • Why Linting and Formatting Are Critical
  • What is ESLint?
  • What is Prettier?
  • How ESLint and Prettier Work Together
  • Setting Up ESLint in a TypeScript Project
    • Installing Dependencies
    • Configuring ESLint
    • Common ESLint Rules for TypeScript
  • Setting Up Prettier
    • Installing Prettier
    • Configuring Prettier
  • Integrating ESLint with Prettier
  • Adding Scripts to package.json
  • Best Practices for Linting and Formatting
  • Conclusion

Introduction

Maintaining clean, consistent, and error-free code is crucial in any project — especially TypeScript, where large teams often collaborate on a growing codebase.

Linting catches mistakes early, and formatting ensures that code style is consistent.
Together, ESLint and Prettier create a robust development workflow that reduces bugs and increases readability.

In this guide, we’ll do a deep dive into setting up ESLint and Prettier in a TypeScript project.


Why Linting and Formatting Are Critical

  • Error Prevention: Catch potential bugs, bad practices, and typos early.
  • Code Consistency: Make your codebase readable for anyone at any time.
  • Team Collaboration: Remove style disputes; formatting is automatic.
  • Faster Reviews: Clean code means reviewers can focus on logic, not syntax.
  • Increased Productivity: Save time on manual formatting and nitpicking.

What is ESLint?

ESLint is a linter for JavaScript and TypeScript.
It analyzes your code to:

  • Find syntax errors
  • Highlight code smells
  • Enforce coding standards
  • Offer auto-fixes for common issues

TypeScript + ESLint is a powerful combo that catches both type-related and general code issues.


What is Prettier?

Prettier is an opinionated code formatter.
It reformats your code automatically according to a set of rules (line width, indentation, quotes, semicolons, etc.).

Unlike ESLint, Prettier doesn’t care about “correctness” — it cares about consistent style.


How ESLint and Prettier Work Together

  • ESLint → Focuses on code quality (errors, bad practices).
  • Prettier → Focuses on code style (formatting).

They overlap a little, but when properly integrated:

  • ESLint catches errors.
  • Prettier formats code automatically.
  • They don’t fight each other.

Setting Up ESLint in a TypeScript Project

1. Installing Dependencies

npm install --save-dev eslint typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin
  • eslint: Main ESLint package
  • @typescript-eslint/parser: Parses TypeScript for ESLint
  • @typescript-eslint/eslint-plugin: Official TypeScript plugin for ESLint rules

2. Configuring ESLint

Create a .eslintrc.js file:

module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {
// Your custom rules
'@typescript-eslint/no-unused-vars': ['error'],
'@typescript-eslint/explicit-function-return-type': 'off',
},
ignorePatterns: ['dist/', 'node_modules/'],
};
  • parser: Tells ESLint to understand TypeScript.
  • extends: Includes recommended ESLint and TypeScript rules.
  • rules: Customize or override specific linting behaviors.

3. Common ESLint Rules for TypeScript

  • @typescript-eslint/no-explicit-any: Warns about use of any.
  • @typescript-eslint/explicit-module-boundary-types: Enforces explicit return types on module boundaries.
  • @typescript-eslint/consistent-type-definitions: Enforces consistent use of interface or type.
  • @typescript-eslint/no-non-null-assertion: Warns when using ! after variables (can cause runtime errors).

You can fine-tune these rules according to your project needs.


Setting Up Prettier

1. Installing Prettier

npm install --save-dev prettier

Optional: Install ESLint-Prettier integration plugins:

npm install --save-dev eslint-config-prettier eslint-plugin-prettier
  • eslint-config-prettier: Turns off ESLint rules that conflict with Prettier.
  • eslint-plugin-prettier: Runs Prettier as an ESLint rule.

2. Configuring Prettier

Create a .prettierrc file:

{
"semi": true,
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"trailingComma": "all",
"arrowParens": "always"
}

You can also add a .prettierignore file:

node_modules
dist
build
coverage

Integrating ESLint with Prettier

Update your .eslintrc.js:

module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'prettier'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended', // Important: Add Prettier last
],
rules: {
'prettier/prettier': 'error', // Prettier errors are shown as ESLint errors
},
ignorePatterns: ['dist/', 'node_modules/'],
};

This ensures:

  • Prettier formats your code.
  • Prettier mistakes are surfaced during linting.

Adding Scripts to package.json

To make your life easier, add these scripts:

{
"scripts": {
"lint": "eslint 'src/**/*.{ts,tsx}'",
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
"format": "prettier --write 'src/**/*.{ts,tsx,json,md}'"
}
}

Now you can:

  • npm run lint: Run ESLint.
  • npm run lint:fix: Auto-fix lint issues.
  • npm run format: Format all files using Prettier.

Best Practices for Linting and Formatting

  • Always lint before pushing: Make it a habit or automate it in CI/CD.
  • Use Prettier auto-format on save: Set it up in your code editor (VSCode recommended).
  • Keep config files version-controlled: .eslintrc, .prettierrc, etc.
  • Don’t fight Prettier: Adjust settings slightly if needed but embrace its philosophy.
  • Group ESLint and Prettier together: Always integrate them so you get best of both.

Conclusion

Linting and formatting are non-negotiable practices in professional TypeScript development.
When set up correctly, ESLint + Prettier create a smooth, error-free, and highly productive environment that keeps your project scalable and maintainable.

With this setup, you’ll spend less time arguing over code style and more time building amazing things.

Clean Code Principles and Best Practices for TypeScript

0
typscript course
typscript course

Table of Contents

  • Introduction
  • Why Clean Code Matters
  • Core Clean Code Principles
    • Meaningful Names
    • Small, Focused Functions
    • Single Responsibility Principle (SRP)
    • Avoiding Side Effects
    • Favor Composition Over Inheritance
    • DRY (Don’t Repeat Yourself)
    • KISS (Keep It Simple, Stupid)
    • YAGNI (You Aren’t Gonna Need It)
  • Best Practices Specific to TypeScript
    • Strong Typing Everywhere
    • Prefer Interfaces over Types (When Appropriate)
    • Use Access Modifiers
    • Make Use of Readonly Properties
    • Use Enums and Literal Types Wisely
    • Use Utility Types
    • Narrow Types Early
    • Exhaustive Checks with never
    • Organize Your Project Structure
    • Linting, Formatting, and Auto-Fixes
  • Conclusion

Introduction

Writing code that works is only the beginning.
Writing code that is readable, maintainable, and scalable is the goal of a true software professional. In TypeScript, thanks to its strong typing system, you have an even better opportunity to write clean, reliable code.

This guide deeply explores clean code principles and TypeScript-specific best practices to help you build better software.


Why Clean Code Matters

  • Easier Maintenance: Well-structured code is easier to understand and modify.
  • Fewer Bugs: Cleaner code tends to be more predictable and less error-prone.
  • Better Collaboration: Teams move faster when the codebase is consistent and readable.
  • Scalability: Clean code is foundational for scaling your application with confidence.
  • Lower Onboarding Cost: New developers can understand and contribute faster.

Core Clean Code Principles

These principles are universal but apply very strongly when writing TypeScript code.

1. Meaningful Names

Bad Example:

const d = new Date();

Good Example:

const currentTimestamp = new Date();

Variables, functions, classes, and interfaces should have clear, descriptive names that reveal their purpose.


2. Small, Focused Functions

Functions should do one thing and do it well. Long functions are hard to understand and debug.

Bad Example:

function processUser(data: any) { /* validation, db save, email send */ }

Good Example:

function validateUser(data: User): ValidationResult { /* ... */ }
function saveUserToDatabase(user: User): Promise<void> { /* ... */ }
function sendWelcomeEmail(user: User): void { /* ... */ }

3. Single Responsibility Principle (SRP)

Each module/class/function should have only one reason to change.

Bad Example: A UserService that handles authentication, user creation, and notifications.

Good Example:

  • AuthenticationService
  • UserService
  • NotificationService

Split responsibilities cleanly.


4. Avoiding Side Effects

Functions should ideally not modify anything outside their scope unless explicitly intended.

Example:

function calculateSum(a: number, b: number): number {
return a + b;
}
// Good: no global mutation, pure function

5. Favor Composition Over Inheritance

Composition offers better flexibility and less coupling than deep inheritance chains.

Instead of:
A giant parent class with many child classes.

Prefer:
Smaller classes composed together via dependency injection or simple object aggregation.


6. DRY (Don’t Repeat Yourself)

Avoid duplicating logic. If you find yourself copying and pasting code, refactor it into reusable components or functions.


7. KISS (Keep It Simple, Stupid)

Over-engineering is a real risk. Always aim for the simplest solution that solves the problem.


8. YAGNI (You Aren’t Gonna Need It)

Don’t build features “just in case.” Focus only on current requirements.


Best Practices Specific to TypeScript

TypeScript brings powerful features that support clean code development if used wisely.


1. Strong Typing Everywhere

Avoid using any unless absolutely necessary.

Bad Example:

function calculateArea(shape: any) { /* ... */ }

Good Example:

interface Rectangle { width: number; height: number; }
function calculateArea(shape: Rectangle) { /* ... */ }

2. Prefer Interfaces Over Types (When Appropriate)

Interfaces are extendable and better for object structures.

interface User {
id: string;
name: string;
}

Use type when combining unions or working with primitives, otherwise prefer interface.


3. Use Access Modifiers

Use public, private, and protected to enforce correct object encapsulation.

class User {
private password: string;
public username: string;

constructor(username: string, password: string) {
this.username = username;
this.password = password;
}
}

4. Make Use of Readonly Properties

Prevent accidental changes to critical properties.

interface Config {
readonly apiUrl: string;
}

5. Use Enums and Literal Types Wisely

For fixed sets of values, use enums or literal types.

type Role = 'admin' | 'editor' | 'viewer';

Or:

enum Role {
Admin = 'admin',
Editor = 'editor',
Viewer = 'viewer',
}

6. Use Utility Types

TypeScript provides powerful built-in utilities like Partial, Pick, Record, Readonly.

Example:

type PartialUser = Partial<User>;

It keeps your code DRY and readable.


7. Narrow Types Early

Use type narrowing techniques like typeof, instanceof, and custom type guards to reduce risk.

function process(value: string | number) {
if (typeof value === 'string') {
// string logic
} else {
// number logic
}
}

8. Exhaustive Checks with never

Ensure all possible cases are handled in switch statements.

function handleEvent(event: 'click' | 'hover') {
switch (event) {
case 'click':
break;
case 'hover':
break;
default:
const exhaustiveCheck: never = event;
throw new Error(`Unhandled event: ${exhaustiveCheck}`);
}
}

9. Organize Your Project Structure

A clean project structure is as important as clean code:

  • Group by feature, not by file type.
  • Use barrel files (index.ts) for exports.
  • Separate domain models, services, and utilities.

Example project structure:

src/
users/
UserService.ts
UserModel.ts
auth/
AuthService.ts
utils/
validators.ts

10. Linting, Formatting, and Auto-Fixes

Use tools like:

  • ESLint with TypeScript plugin
  • Prettier for consistent formatting
  • Husky for pre-commit hooks

Set strict rules in tsconfig.json (e.g., strict: true, noImplicitAny: true).


Conclusion

Clean code isn’t just about beauty—it’s about building systems that last.
By applying core clean code principles and TypeScript best practices, you will create codebases that are:

  • Easier to read
  • Less buggy
  • More maintainable
  • Highly scalable

Focus on strong typing, clear organization, modularization, and consistent formatting to master clean TypeScript development.

Event-Driven Programming with Typed Events in TypeScript

0
typscript course
typscript course

Table of Contents

  • Introduction
  • What is Event-Driven Programming (EDP)?
  • Why Typed Events Matter
  • Building a Simple Event System in TypeScript
    • Defining Typed Event Interfaces
    • Creating an Event Emitter Class
    • Subscribing, Emitting, and Unsubscribing
  • Example: Building a Notification System
  • Advantages of Typed Events
  • Best Practices
  • Conclusion

Introduction

Event-Driven Programming (EDP) is a fundamental paradigm where the flow of a program is determined by events such as user actions, sensor outputs, or messages from other programs. In large applications, especially, events decouple components and make systems more scalable and responsive.

TypeScript adds an extra layer of safety and clarity to EDP through typed events, ensuring that both event emitters and listeners agree on the event data structures.

In this article, we’ll do a deep dive into Event-Driven Programming using strongly typed events in TypeScript.


What is Event-Driven Programming (EDP)?

In Event-Driven Programming:

  • A producer emits an event when something happens (e.g., button click, data received).
  • One or more consumers (listeners) react to the event by executing callback functions.

This model allows:

  • Loose coupling between components.
  • Asynchronous, reactive flows.
  • Cleaner separation of concerns.

Familiar examples:

  • UI libraries (React, Vue, Angular event handlers).
  • Backend systems (Node.js EventEmitter).
  • Messaging systems (RabbitMQ, Kafka).

Why Typed Events Matter

In JavaScript, event handling often relies on loose, dynamic typing.
Problems with dynamic typing in events:

  • Event payloads might be missing or have unexpected structures.
  • Listeners might assume wrong types, leading to runtime errors.

Solution: TypeScript allows you to strongly type events and event payloads, catching mismatches at compile time.

Typed events ensure:

  • Event names are predictable (string literals or enums).
  • Payloads follow strict contracts.
  • Listeners and emitters stay in sync as the codebase evolves.

Building a Simple Event System in TypeScript

Let’s create a basic, type-safe Event Emitter.

1. Defining Typed Event Interfaces

First, define the list of events and their associated data:

interface AppEvents {
userCreated: { userId: string; username: string };
userDeleted: { userId: string };
errorOccurred: { message: string; code: number };
}

Each key represents an event name, and its value is the type of payload it emits.


2. Creating an Event Emitter Class

Let’s create a generic EventEmitter that uses the AppEvents type.

type EventHandler<T> = (payload: T) => void;

class TypedEventEmitter<Events extends Record<string, any>> {
private listeners: {
[K in keyof Events]?: EventHandler<Events[K]>[];
} = {};

on<K extends keyof Events>(eventName: K, handler: EventHandler<Events[K]>) {
if (!this.listeners[eventName]) {
this.listeners[eventName] = [];
}
this.listeners[eventName]!.push(handler);
}

off<K extends keyof Events>(eventName: K, handler: EventHandler<Events[K]>) {
if (!this.listeners[eventName]) return;
this.listeners[eventName] = this.listeners[eventName]!.filter(h => h !== handler);
}

emit<K extends keyof Events>(eventName: K, payload: Events[K]) {
if (!this.listeners[eventName]) return;
for (const handler of this.listeners[eventName]!) {
handler(payload);
}
}
}

Now, on, off, and emit are fully type-checked!


3. Subscribing, Emitting, and Unsubscribing

const emitter = new TypedEventEmitter<AppEvents>();

// Subscribe
emitter.on('userCreated', (payload) => {
console.log(`New user created: ${payload.username}`);
});

// Emit
emitter.emit('userCreated', { userId: '123', username: 'JohnDoe' });

// Unsubscribe
const handler = (payload: { userId: string }) => {
console.log(`User deleted: ${payload.userId}`);
};
emitter.on('userDeleted', handler);

// Later...
emitter.off('userDeleted', handler);

Type Safety in Action

If you make a mistake, TypeScript will catch it:

// ❌ Type Error: missing 'userId'
emitter.emit('userDeleted', { id: '123' });

// ❌ Type Error: unexpected event name
emitter.on('nonExistentEvent', () => {});

Example: Building a Notification System

Let’s build a real-world-like notification service.

interface NotificationEvents {
newMessage: { from: string; content: string };
userOnline: { userId: string };
userOffline: { userId: string };
}

const notificationEmitter = new TypedEventEmitter<NotificationEvents>();

// UI component listens for new messages
notificationEmitter.on('newMessage', (payload) => {
console.log(`[Message] ${payload.from}: ${payload.content}`);
});

// System updates online status
notificationEmitter.on('userOnline', ({ userId }) => {
console.log(`User ${userId} is now online.`);
});

// Emit events
notificationEmitter.emit('newMessage', { from: 'Alice', content: 'Hi there!' });
notificationEmitter.emit('userOnline', { userId: 'user123' });

Advantages of Typed Events

  • Compile-Time Safety: Mistyped event names or wrong payload structures are caught during development.
  • Better Developer Experience: IntelliSense/Autocomplete support for event names and payloads.
  • Clearer Contracts: Easily understand what data flows through events.
  • Easier Refactoring: Rename event names or change payload types confidently.
  • Reduced Bugs: Avoid runtime surprises caused by loose typing.

Best Practices

  • Centralize Event Definitions: Use a single interface (like AppEvents) to avoid duplication and ensure synchronization.
  • Use Narrow Payload Types: Avoid using broad types like any.
  • Avoid Too Many Global Events: Group events by bounded contexts (Domain Events, UI Events, etc.).
  • Prefer Strong Typing Even in Third-Party Libraries: If a library’s events are not typed, create your own thin wrapper.
  • Clean Up Listeners: Always unsubscribe (off) listeners to prevent memory leaks.

Conclusion

Typed Event-Driven Programming in TypeScript brings the best of both worlds: the flexibility of event systems and the safety of static typing.
By defining typed event maps and building type-safe emitters, you create robust and scalable systems that are easy to maintain and extend.

As applications grow, typed events become even more valuable for avoiding fragile, error-prone event handling logic.

Domain-Driven Design (DDD) Basics in TypeScript

0
typscript course
typscript course

Table of Contents

  • Introduction
  • What is Domain-Driven Design (DDD)?
  • Core Concepts of DDD
    • Entities
    • Value Objects
    • Aggregates
    • Repositories
    • Services
  • Structuring a DDD Project in TypeScript
  • Example: Building a Simple DDD Module in TypeScript
  • Best Practices for DDD in TypeScript
  • Conclusion

Introduction

As software systems grow in complexity, organizing code around technical concerns (controllers, services, repositories) becomes insufficient. Domain-Driven Design (DDD) offers a structured approach to tackling complex business problems by modeling software after real-world domains.

TypeScript, with its strong typing and object-oriented features, is a powerful language for applying DDD effectively.
This article explores DDD basics in TypeScript with practical examples.


What is Domain-Driven Design (DDD)?

Domain-Driven Design (DDD) is a methodology and set of principles for developing software systems that revolve around the core domain and domain logic.

Eric Evans introduced DDD to help teams model complex business domains accurately and maintain a shared understanding between domain experts and developers.

In short, DDD emphasizes:

  • Focusing on business logic.
  • Structuring code around the domain rather than technical layers.
  • Using a common language (Ubiquitous Language) shared between developers and business stakeholders.

Core Concepts of DDD

Let’s understand the main building blocks of DDD with TypeScript examples.

1. Entities

An Entity is an object that has a distinct identity that runs through time and different states.

Example:

class User {
constructor(
public readonly id: string,
public name: string,
public email: string
) {}

changeEmail(newEmail: string) {
this.email = newEmail;
}
}
  • id uniquely identifies the User.
  • The identity remains even if other fields change.

2. Value Objects

Value Objects are immutable and distinguishable only by their properties, not by identity.

Example:

class Email {
constructor(private readonly email: string) {
if (!this.validate(email)) {
throw new Error('Invalid email format.');
}
}

private validate(email: string): boolean {
return /\S+@\S+\.\S+/.test(email);
}

toString(): string {
return this.email;
}
}
  • No id.
  • Two Email instances with the same email address are considered equal.

3. Aggregates

An Aggregate is a cluster of domain objects (Entities and Value Objects) treated as a single unit.

The Aggregate Root is the only entry point for accessing objects inside the aggregate.

Example:

class OrderItem {
constructor(public productId: string, public quantity: number) {}
}

class Order {
private items: OrderItem[] = [];

constructor(public readonly id: string) {}

addItem(productId: string, quantity: number) {
const item = new OrderItem(productId, quantity);
this.items.push(item);
}

getItems(): OrderItem[] {
return [...this.items]; // return a copy to prevent outside mutation
}
}
  • Order is the Aggregate Root.
  • OrderItem objects are managed by Order.

4. Repositories

A Repository provides an abstraction to access and manage aggregates without exposing storage details (DB, API, etc.).

Example:

interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
}

An in-memory implementation:

class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();

async save(user: User): Promise<void> {
this.users.set(user.id, user);
}

async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
}

5. Services

A Service contains domain logic that doesn’t naturally fit inside an Entity or Value Object.

Example:

class PaymentService {
processPayment(orderId: string, amount: number) {
// Connect to a payment gateway
console.log(`Processing payment for order ${orderId} with amount ${amount}`);
}
}

Structuring a DDD Project in TypeScript

A basic structure for a DDD TypeScript project could look like:

/src
/domain
/user
- User.ts (Entity)
- Email.ts (Value Object)
- UserRepository.ts (Repository Interface)
/infrastructure
- InMemoryUserRepository.ts
/application
- UserService.ts (Application/Domain Service)
/presentation
- UserController.ts (Optional: API/HTTP Layer)
  • Domain: Business rules, pure domain logic.
  • Infrastructure: DBs, APIs, external dependencies.
  • Application: Use cases and service orchestration.
  • Presentation: HTTP controllers, GraphQL resolvers, etc.

Example: Building a Simple DDD Module in TypeScript

Let’s quickly wire up a simple module.

User Entity

class User {
constructor(
public readonly id: string,
public name: string,
public readonly email: Email
) {}

updateName(newName: string) {
this.name = newName;
}
}

Email Value Object

class Email {
constructor(private readonly value: string) {
if (!/\S+@\S+\.\S+/.test(value)) {
throw new Error('Invalid email format.');
}
}

getValue(): string {
return this.value;
}
}

User Repository Interface

interface IUserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}

In-Memory Implementation

class InMemoryUserRepository implements IUserRepository {
private users = new Map<string, User>();

async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}

async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
}

Application Service

class UserService {
constructor(private userRepository: IUserRepository) {}

async registerUser(id: string, name: string, emailValue: string) {
const email = new Email(emailValue);
const user = new User(id, name, email);
await this.userRepository.save(user);
}
}

Best Practices for DDD in TypeScript

  • Separate domain and infrastructure: Keep core business logic pure.
  • Use Interfaces heavily: Especially for Repositories and external dependencies.
  • Focus on the Ubiquitous Language: Name entities, methods, and services exactly how the business describes them.
  • Prefer immutability: Especially for Value Objects.
  • Guard Aggregate integrity: Aggregates should enforce all business rules internally.
  • Write tests against domain logic: Your domain should be easily unit testable without databases.

Conclusion

Domain-Driven Design is a powerful approach to tackling complex business software projects.
In TypeScript, with strong typing and class-based OOP features, DDD becomes more structured and maintainable.

When applied correctly, DDD leads to:

  • Cleaner architecture
  • Better communication between teams
  • High maintainability
  • Easier scaling and evolution of systems

As you move forward, try applying DDD patterns gradually, starting small — maybe just in one bounded context — and then expanding as your system grows.