Skip to main content

Domain-Driven Design in Next.js

Learn how to implement Domain-Driven Design in a Next.js application using TypeScript.

27 Aug 2025 20 min read
views
The logo of Domain-Driven Design in Next.js

Domain-Driven Design in Next.js: A Complete Guide

Introduction

Domain-Driven Design (DDD) is a software development approach that focuses on creating a rich model of the business domain. When combined with Next.js, it provides a robust architecture for building scalable web applications. This guide demonstrates how to implement DDD principles in a Next.js application using TypeScript.

Why DDD with Next.js?

  • Separation of Concerns: Clear boundaries between business logic and UI
  • Testability: Business logic independent of framework
  • Maintainability: Easy to modify and extend
  • Scalability: Supports large, complex applications
  • Type Safety: Full TypeScript support throughout all layers

Project Architecture Overview

ecommerce-app/
├── apps/web/                 # Next.js Application Layer
│   └── src/
│       ├── app/              # Next.js App Router pages
│       ├── components/       # UI components
│       ├── products/         # Feature-specific modules
│       │   ├── actions/      # Server actions
│       │   ├── components/   # Feature components
│       │   └── forms/        # Form schemas and components
│       ├── orders/           # Order management features
│       ├── users/            # User management features
│       └── lib/              # App-specific utilities
│
├── packages/
│   ├── domain/               # Domain Layer (Pure Business Logic)
│   │   ├── entities/         # Domain entities and value objects
│   │   ├── repositories/     # Repository interfaces
│   │   └── use-cases/        # Business use cases
│   │
│   ├── infrastructure/       # Infrastructure Layer
│   │   ├── database/         # Database implementations
│   │   ├── external-api/     # Third-party API integrations
│   │   ├── logging/          # Logging services
│   │   └── cache/            # Caching services
│   │
│   └── ui/                   # Shared UI components

Key Architectural Principles

  1. Dependency Inversion: High-level modules don’t depend on low-level modules
  2. Clean Architecture: Dependencies point inward toward the domain
  3. Repository Pattern: Abstraction over data access
  4. Use Case Pattern: Encapsulation of business operations
  5. Result Pattern: Functional error handling

Domain Layer

The domain layer contains the core business logic and is completely independent of any external frameworks or libraries.

Domain Objects

Domain objects represent the core business entities and concepts:

// packages/domain/entities/Product.ts
export type Product = {
  id: string;
  name: string;
  description: string;
  price: Money;
  category: ProductCategory;
  inventory: number;
  images: Image[];
  status: ProductStatus;
  createdAt: Date;
  updatedAt: Date;
};
 
export type ProductStatus = "ACTIVE" | "INACTIVE" | "OUT_OF_STOCK";
 
export type ProductCategory = {
  id: string;
  name: string;
  slug: string;
};
 
export type ProductCreateInput = {
  name: string;
  description: string;
  price: Money;
  categoryId: string;
  inventory: number;
  imageUrls: string[];
};
 
export type ProductError = BaseError<
  | "PRODUCT_NOT_FOUND_ERROR"
  | "PRODUCT_CREATE_ERROR"
  | "PRODUCT_UPDATE_ERROR"
  | "PRODUCT_INSUFFICIENT_INVENTORY_ERROR"
  | "PRODUCT_INVALID_PRICE_ERROR"
>;

Repository Interfaces

Repositories define contracts for data access without implementation details:

// packages/domain/repositories/ProductRepository.ts
export type ProductRepository<TErr = ProductError | NetworkError> = {
  create: (input: {
    userId: string;
    productData: ProductCreateInput;
  }) => AsyncResult<Pick<Product, "id">, TErr>;
  
  getById: (input: {
    id: string;
  }) => AsyncResult<Product, TErr>;
  
  getAll: (input: {
    categoryId?: string;
    status?: ProductStatus;
    skip?: number;
    take?: number;
  }) => AsyncResult<{
    data: Product[];
    pageInfo: PageInfo;
    total: number;
  }, TErr>;
  
  update: (input: {
    id: string;
    productData: Partial<ProductCreateInput>;
  }) => AsyncResult<Product, TErr>;
  
  updateInventory: (input: {
    id: string;
    quantity: number;
  }) => AsyncResult<{ success: true }, TErr>;
  
  delete: (input: {
    id: string;
  }) => AsyncResult<{ success: true }, TErr>;
};

Error Handling with Result Pattern

The domain uses a Result pattern for functional error handling:

// packages/domain/objects/Result.ts
export type Ok<T> = { data: T; ok: true };
export type Err<E> = { error: E; ok: false };
 
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
export const ok = <T>(data: T): Ok<T> => ({ ok: true, data });
 
export type Result<T, E> = Ok<T> | Err<E>;
export type AsyncResult<T, E> = Promise<Ok<T> | Err<E>>;
 
// Base error structure
export type BaseError<T> = {
  code: T;
  field?: string | null;
  message: string;
};

Use Cases (Application Layer)

Use cases encapsulate business operations and coordinate between domain objects and repositories.

Use Case Structure

// packages/domain/use-cases/product/CreateProduct.ts
export const createProductUseCase = async (
  deps: { 
    productRepository: ProductRepository;
    categoryRepository: CategoryRepository;
  },
  input: { 
    userId: string; 
    productData: ProductCreateInput;
  },
): AsyncResult<Pick<Product, "id">, ProductError | CategoryError | NetworkError> => {
  // 1. Validate category exists
  const categoryResult = await deps.categoryRepository.getById({
    id: input.productData.categoryId,
  });
 
  if (!categoryResult.ok) {
    return err(categoryResult.error);
  }
 
  // 2. Validate price is positive
  if (input.productData.price.amount <= 0) {
    return err({
      code: "PRODUCT_INVALID_PRICE_ERROR",
      message: "Product price must be greater than 0",
    });
  }
 
  // 3. Create the product
  const productResult = await deps.productRepository.create({
    userId: input.userId,
    productData: input.productData,
  });
 
  if (!productResult.ok) {
    return err(productResult.error);
  }
 
  return ok(productResult.data);
};

Complex Use Case Example

// packages/domain/use-cases/order/CreateOrder.ts
export const createOrderUseCase = async (
  deps: { 
    orderRepository: OrderRepository;
    productRepository: ProductRepository;
    userRepository: UserRepository;
    paymentService: PaymentService;
  },
  input: {
    userId: string;
    items: OrderItemInput[];
    shippingAddress: Address;
    paymentMethodId: string;
  },
): AsyncResult<Pick<Order, "id">, OrderError | ProductError | PaymentError | NetworkError> => {
  // Step 1: Validate user exists
  const userResult = await deps.userRepository.getById({
    id: input.userId,
  });
 
  if (!userResult.ok) {
    return err(userResult.error);
  }
 
  // Step 2: Validate all products and check inventory
  const productValidations = await Promise.all(
    input.items.map(async (item) => {
      const productResult = await deps.productRepository.getById({
        id: item.productId,
      });
 
      if (!productResult.ok) {
        return productResult;
      }
 
      if (productResult.data.inventory < item.quantity) {
        return err({
          code: "PRODUCT_INSUFFICIENT_INVENTORY_ERROR",
          message: `Insufficient inventory for product ${item.productId}`,
        });
      }
 
      return ok(productResult.data);
    }),
  );
 
  // Check if any product validation failed
  const failedValidation = productValidations.find((result) => !result.ok);
  if (failedValidation && !failedValidation.ok) {
    return err(failedValidation.error);
  }
 
  // Step 3: Calculate total amount
  const products = productValidations.map((result) => result.data!);
  const totalAmount = input.items.reduce((sum, item, index) => {
    return sum + (products[index].price.amount * item.quantity);
  }, 0);
 
  // Step 4: Process payment
  const paymentResult = await deps.paymentService.processPayment({
    amount: { amount: totalAmount, currency: "USD" },
    paymentMethodId: input.paymentMethodId,
    customerId: input.userId,
  });
 
  if (!paymentResult.ok) {
    return err(paymentResult.error);
  }
 
  // Step 5: Create the order
  const orderResult = await deps.orderRepository.create({
    userId: input.userId,
    items: input.items,
    shippingAddress: input.shippingAddress,
    totalAmount: { amount: totalAmount, currency: "USD" },
    paymentId: paymentResult.data.paymentId,
  });
 
  if (!orderResult.ok) {
    // Refund payment if order creation fails
    await deps.paymentService.refundPayment({
      paymentId: paymentResult.data.paymentId,
    });
    return err(orderResult.error);
  }
 
  // Step 6: Update product inventory
  await Promise.all(
    input.items.map((item) =>
      deps.productRepository.updateInventory({
        id: item.productId,
        quantity: -item.quantity, // Decrease inventory
      }),
    ),
  );
 
  return ok(orderResult.data);
};

Use Case Benefits

  • Single Responsibility: Each use case handles one business operation
  • Testability: Easy to unit test business logic
  • Dependency Injection: Dependencies injected at runtime
  • Error Handling: Consistent error propagation
  • Composability: Use cases can call other use cases

Infrastructure Layer

The infrastructure layer provides concrete implementations of repository interfaces and external service integrations.

Database Repository Implementation

// packages/infrastructure/database/ProductRepository.ts
export const databaseProductRepository = ({
  db,
  logger,
}: {
  db: Database;
  logger: Logger;
}): ProductRepository => ({
  create: async ({ userId, productData }) => {
    try {
      const productId = generateId();
      
      await db.transaction(async (tx) => {
        // Insert product
        await tx.products.insert({
          id: productId,
          name: productData.name,
          description: productData.description,
          price: productData.price.amount,
          currency: productData.price.currency,
          categoryId: productData.categoryId,
          inventory: productData.inventory,
          status: "ACTIVE",
          createdBy: userId,
          createdAt: new Date(),
          updatedAt: new Date(),
        });
 
        // Insert product images
        await Promise.all(
          productData.imageUrls.map((url, index) =>
            tx.productImages.insert({
              id: generateId(),
              productId,
              url,
              order: index,
            }),
          ),
        );
      });
 
      return ok({ id: productId });
    } catch (error) {
      logger.error("Failed to create product", { error, productData });
 
      return err({
        code: "PRODUCT_CREATE_ERROR",
        message: "Failed to create product",
      });
    }
  },
 
  getById: async ({ id }) => {
    try {
      const productRow = await db.products
        .select("*")
        .where("id", id)
        .where("status", "!=", "DELETED")
        .first();
 
      if (!productRow) {
        return err({
          code: "PRODUCT_NOT_FOUND_ERROR",
          message: `Product ${id} not found`,
        });
      }
 
      const images = await db.productImages
        .select("*")
        .where("productId", id)
        .orderBy("order");
 
      const category = await db.categories
        .select("*")
        .where("id", productRow.categoryId)
        .first();
 
      const product: Product = {
        id: productRow.id,
        name: productRow.name,
        description: productRow.description,
        price: {
          amount: productRow.price,
          currency: productRow.currency,
        },
        category: {
          id: category.id,
          name: category.name,
          slug: category.slug,
        },
        inventory: productRow.inventory,
        images: images.map((img) => ({
          url: img.url,
          alt: productRow.name,
        })),
        status: productRow.status,
        createdAt: productRow.createdAt,
        updatedAt: productRow.updatedAt,
      };
 
      return ok(product);
    } catch (error) {
      logger.error("Failed to get product", { error, id });
 
      return err({
        code: "PRODUCT_NOT_FOUND_ERROR",
        message: "Failed to retrieve product",
      });
    }
  },
 
  updateInventory: async ({ id, quantity }) => {
    try {
      const updated = await db.products
        .where("id", id)
        .increment("inventory", quantity);
 
      if (updated === 0) {
        return err({
          code: "PRODUCT_NOT_FOUND_ERROR",
          message: `Product ${id} not found`,
        });
      }
 
      return ok({ success: true });
    } catch (error) {
      logger.error("Failed to update inventory", { error, id, quantity });
 
      return err({
        code: "PRODUCT_UPDATE_ERROR",
        message: "Failed to update product inventory",
      });
    }
  },
 
  // ... other methods
});

HTTP Client

// packages/infrastructure/external-api/http-client.ts
export const httpClient = (
  baseUrl: string,
  apiKey?: string,
) => ({
  get: async <TResult = any>(
    endpoint: string,
    options?: {
      params?: Record<string, string>;
      headers?: Record<string, string>;
    },
  ): Promise<Result<TResult, NetworkError>> => {
    try {
      const url = new URL(endpoint, baseUrl);
      
      if (options?.params) {
        Object.entries(options.params).forEach(([key, value]) => {
          url.searchParams.append(key, value);
        });
      }
 
      const response = await fetch(url.toString(), {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
          ...(apiKey && { Authorization: `Bearer ${apiKey}` }),
          ...options?.headers,
        },
      });
 
      if (!response.ok) {
        return err({
          status: response.status,
          code: response.status === 404 ? "NOT_FOUND_ERROR" : "UNEXPECTED_HTTP_ERROR",
          message: response.statusText,
        });
      }
 
      const data = await response.json();
      return ok(data);
    } catch (error) {
      return err({
        code: "UNEXPECTED_HTTP_ERROR",
        message: "Network request failed",
        status: 500,
      });
    }
  },
 
  post: async <TResult = any, TBody = any>(
    endpoint: string,
    body: TBody,
    options?: {
      headers?: Record<string, string>;
    },
  ): Promise<Result<TResult, NetworkError>> => {
    try {
      const response = await fetch(`${baseUrl}${endpoint}`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          ...(apiKey && { Authorization: `Bearer ${apiKey}` }),
          ...options?.headers,
        },
        body: JSON.stringify(body),
      });
 
      if (!response.ok) {
        return err({
          status: response.status,
          code: "UNEXPECTED_HTTP_ERROR",
          message: response.statusText,
        });
      }
 
      const data = await response.json();
      return ok(data);
    } catch (error) {
      return err({
        code: "UNEXPECTED_HTTP_ERROR",
        message: "Network request failed",
        status: 500,
      });
    }
  },
});

Next.js Application Layer

The Next.js application layer handles HTTP requests, routing, and UI rendering while delegating business logic to use cases.

Server Actions

Server actions bridge the UI layer with business logic:

// apps/web/src/products/actions/create-product-action.ts
"use server";
 
import { redirect } from "next/navigation";
import { revalidateTag } from "next/cache";
 
export const createProductAction = async (
  productData: CreateProductFormValues,
) => {
  const { userId } = await requireAuth();
 
  const productResult = await createProductUseCase(
    {
      productRepository: databaseProductRepository({
        db: getDatabase(),
        logger: getLogger(),
      }),
      categoryRepository: databaseCategoryRepository({
        db: getDatabase(),
        logger: getLogger(),
      }),
    },
    {
      userId,
      productData: {
        name: productData.name,
        description: productData.description,
        price: {
          amount: productData.price,
          currency: "USD",
        },
        categoryId: productData.categoryId,
        inventory: productData.inventory,
        imageUrls: productData.imageUrls,
      },
    },
  );
 
  if (!productResult.ok) {
    return { error: productResult.error };
  }
 
  // Revalidate product listings
  revalidateTag("products:all");
  revalidateTag(`category:${productData.categoryId}`);
 
  redirect(`/products/${productResult.data.id}`);
};

Page Components

Page components use use cases to fetch data:

// apps/web/src/app/products/[productId]/page.tsx
export default async function ProductPage({
  params: { productId },
}: {
  params: { productId: string };
}) {
  const productResult = await getProductByIdUseCase(
    {
      productRepository: databaseProductRepository({
        db: getDatabase(),
        logger: getLogger(),
      }),
    },
    { id: productId },
  );
 
  if (!productResult.ok) {
    if (productResult.error.code === "PRODUCT_NOT_FOUND_ERROR") {
      notFound();
    }
    
    redirect(`/error?code=${productResult.error.code}`);
  }
 
  return (
    <div className="container mx-auto px-4 py-8">
      <ProductDetails product={productResult.data} />
      <AddToCartButton productId={productResult.data.id} />
    </div>
  );
}
 
// Generate metadata for SEO
export async function generateMetadata({
  params: { productId },
}: {
  params: { productId: string };
}): Promise<Metadata> {
  const productResult = await getProductByIdUseCase(
    {
      productRepository: databaseProductRepository({
        db: getDatabase(),
        logger: getLogger(),
      }),
    },
    { id: productId },
  );
 
  if (!productResult.ok) {
    return {
      title: "Product Not Found",
    };
  }
 
  return {
    title: productResult.data.name,
    description: productResult.data.description,
    openGraph: {
      title: productResult.data.name,
      description: productResult.data.description,
      images: productResult.data.images.map((img) => img.url),
    },
  };
}

Helper Functions

Helper functions encapsulate common patterns:

// apps/web/src/lib/product-helpers.ts
export const getProductOr404 = async (productId: string): Promise<Product> => {
  const result = await getProductByIdUseCase(
    {
      productRepository: databaseProductRepository({
        db: getDatabase(),
        logger: getLogger(),
      }),
    },
    { id: productId },
  );
 
  if (!result.ok) {
    notFound();
  }
 
  return result.data;
};
 
export const requireProductInStock = async (
  productId: string,
  quantity: number,
): Promise<Product> => {
  const product = await getProductOr404(productId);
 
  if (product.status !== "ACTIVE") {
    throw new Error("Product is not available");
  }
 
  if (product.inventory < quantity) {
    throw new Error("Insufficient inventory");
  }
 
  return product;
};
 
export const calculateOrderTotal = (items: CartItem[]): Money => {
  const total = items.reduce((sum, item) => {
    return sum + (item.product.price.amount * item.quantity);
  }, 0);
 
  return {
    amount: total,
    currency: "USD",
  };
};

Error Handling Strategy

Domain Error Types

// Domain-specific errors
export type ProductError = BaseError<
  | "PRODUCT_NOT_FOUND_ERROR"
  | "PRODUCT_CREATE_ERROR"
  | "PRODUCT_UPDATE_ERROR"
  | "PRODUCT_INSUFFICIENT_INVENTORY_ERROR"
  | "PRODUCT_INVALID_PRICE_ERROR"
>;
 
export type OrderError = BaseError<
  | "ORDER_NOT_FOUND_ERROR"
  | "ORDER_CREATE_ERROR"
  | "ORDER_INVALID_ITEMS_ERROR"
  | "ORDER_PAYMENT_FAILED_ERROR"
>;
 
export type UserError = BaseError<
  | "USER_NOT_FOUND_ERROR"
  | "USER_UNAUTHORIZED_ERROR"
  | "USER_INVALID_CREDENTIALS_ERROR"
>;
 
// Network errors
export type NetworkError = BaseError<NetworkErrorCode> & { status: number };
export type NetworkErrorCode = 
  | "BAD_REQUEST_ERROR"
  | "NOT_FOUND_ERROR"
  | "UNAUTHORIZED_ERROR"
  | "UNEXPECTED_HTTP_ERROR";

Error Propagation

// Use case level
if (!productResult.ok) {
  return err(productResult.error);
}
 
// Application level
if (!productResult.ok) {
  if (productResult.error.code === "PRODUCT_NOT_FOUND_ERROR") {
    notFound();
  }
  
  redirect(`/error?code=${productResult.error.code}`);
}
 
// Server action level
if (!orderResult.ok) {
  return {
    error: {
      code: orderResult.error.code,
      message: orderResult.error.message,
      field: orderResult.error.field,
    },
  };
}

Global Error Handling

// apps/web/src/app/global-error.tsx
"use client";
 
import { useEffect } from "react";
import { getLogger } from "@/lib/logger";
 
const logger = getLogger();
 
export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    logger.error("Unhandled application error", { 
      error: error.message,
      stack: error.stack,
      digest: error.digest,
    });
  }, [error]);
 
  return (
    <html>
      <body>
        <div className="flex min-h-screen flex-col items-center justify-center">
          <h1 className="text-2xl font-bold text-red-600">
            Something went wrong!
          </h1>
          <p className="mt-2 text-gray-600">
            An unexpected error occurred. Our team has been notified.
          </p>
          <button
            onClick={reset}
            className="mt-4 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
          >
            Try again
          </button>
        </div>
      </body>
    </html>
  );
}

Dependency Injection

Runtime Dependency Injection

Dependencies are injected at runtime, making the system flexible and testable:

// Use case call with injected dependencies
const result = await createProductUseCase(
  {
    productRepository: databaseProductRepository({
      db: getDatabase(),
      logger: getLogger(),
    }),
    categoryRepository: databaseCategoryRepository({
      db: getDatabase(),
      logger: getLogger(),
    }),
  },
  {
    userId,
    productData,
  },
);

Repository Factory Pattern

// Factory for creating repositories with configuration
const createRepositories = (config: {
  db: Database;
  logger: Logger;
  cache?: CacheService;
}) => ({
  productRepository: databaseProductRepository(config),
  orderRepository: databaseOrderRepository(config),
  userRepository: databaseUserRepository(config),
  categoryRepository: databaseCategoryRepository(config),
});
 
// Environment-specific factory
const createProductionRepositories = () => 
  createRepositories({
    db: getProductionDatabase(),
    logger: getProductionLogger(),
    cache: getRedisCache(),
  });
 
const createTestRepositories = () => 
  createRepositories({
    db: getTestDatabase(),
    logger: getTestLogger(),
    cache: getInMemoryCache(),
  });

Best Practices

1. Keep Domain Pure

// ✅ Good - Pure domain logic
export type Product = {
  id: string;
  name: string;
  price: Money;
  status: ProductStatus;
};
 
// ❌ Bad - Framework dependency in domain
import { NextRequest } from 'next/server';
export type Product = {
  id: string;
  name: string;
  request: NextRequest; // Framework dependency
};

2. Use Meaningful Error Codes

// ✅ Good - Descriptive error codes
export type ProductError = BaseError<
  | "PRODUCT_NOT_FOUND_ERROR"
  | "PRODUCT_INSUFFICIENT_INVENTORY_ERROR"
  | "PRODUCT_INVALID_PRICE_ERROR"
>;
 
// ❌ Bad - Generic error codes
export type ProductError = BaseError<
  | "ERROR_1"
  | "ERROR_2"
>;

3. Implement Repository Pattern Correctly

// ✅ Good - Interface in domain, implementation in infrastructure
// Domain layer
export interface UserRepository {
  findById(id: string): Promise<Result<User, UserError>>;
}
 
// Infrastructure layer
export class GraphQLUserRepository implements UserRepository {
  async findById(id: string): Promise<Result<User, UserError>> {
    // Implementation details
  }
}

4. Use Type-Safe Result Pattern

// ✅ Good - Type-safe error handling
const result = await createProductUseCase(deps, input);
if (!result.ok) {
  // TypeScript knows result.error is ProductError | CategoryError | NetworkError
  console.error(result.error.code);
  return;
}
// TypeScript knows result.data is Pick<Product, "id">
console.log(result.data.id);

5. Organize by Feature

apps/web/src/
├── products/
│   ├── actions/
│   ├── components/
│   └── forms/
├── orders/
│   ├── actions/
│   ├── components/
│   └── forms/
├── users/
│   ├── actions/
│   ├── components/
│   └── forms/
└── categories/
    ├── actions/
    ├── components/
    └── forms/

Common Patterns

1. CQRS (Command Query Responsibility Segregation)

// Command (Write operation)
export const createProductUseCase = async (
  deps: { 
    productRepository: ProductRepository;
    categoryRepository: CategoryRepository;
  },
  input: CreateProductInput,
): AsyncResult<{ id: string }, ProductError | CategoryError> => {
  // Write logic with validation and business rules
};
 
// Query (Read operation)
export const getProductByIdUseCase = async (
  deps: { productRepository: ProductRepository },
  input: { id: string },
): AsyncResult<Product, ProductError> => {
  // Read logic optimized for retrieval
};
 
// Complex query operation
export const searchProductsUseCase = async (
  deps: { productRepository: ProductRepository },
  input: { 
    query: string;
    categoryId?: string;
    priceRange?: { min: number; max: number };
    skip?: number;
    take?: number;
  },
): AsyncResult<{ products: Product[]; total: number }, ProductError> => {
  // Search logic
};

2. Factory Pattern for Repositories

export const createInfrastructure = (config: InfrastructureConfig) => ({
  productRepository: databaseProductRepository(config),
  orderRepository: databaseOrderRepository(config),
  userRepository: databaseUserRepository(config),
  paymentService: stripePaymentService(config),
  emailService: resendEmailService(config),
  // ... other services
});

3. Strategy Pattern for Different Implementations

// Different repository implementations
export const mockProductRepository = (): ProductRepository => ({
  create: async () => ok({ id: "mock-id" }),
  getById: async () => ok(mockProduct),
  getAll: async () => ok({ data: [mockProduct], pageInfo: mockPageInfo, total: 1 }),
  update: async () => ok(mockProduct),
  updateInventory: async () => ok({ success: true }),
  delete: async () => ok({ success: true }),
});
 
export const databaseProductRepository = (config: DatabaseConfig): ProductRepository => ({
  // Database implementation using SQL
});
 
export const cacheProductRepository = (
  baseRepository: ProductRepository,
  cache: CacheService
): ProductRepository => ({
  // Cached wrapper implementation
});

Testing Strategy

Unit Testing Use Cases

describe('createProductUseCase', () => {
  it('should create product successfully', async () => {
    // Arrange
    const mockProductRepository: ProductRepository = {
      create: jest.fn().mockResolvedValue(ok({ id: 'product-123' })),
      getById: jest.fn(),
      getAll: jest.fn(),
      update: jest.fn(),
      updateInventory: jest.fn(),
      delete: jest.fn(),
    };
 
    const mockCategoryRepository: CategoryRepository = {
      getById: jest.fn().mockResolvedValue(ok({ 
        id: 'cat-1', 
        name: 'Electronics',
        slug: 'electronics',
      })),
      // ... other methods
    };
    
    const input = {
      userId: 'user-123',
      productData: {
        name: 'Test Product',
        description: 'A test product',
        price: { amount: 9999, currency: 'USD' },
        categoryId: 'cat-1',
        inventory: 10,
        imageUrls: ['http://example.com/image.jpg'],
      },
    };
 
    // Act
    const result = await createProductUseCase(
      { 
        productRepository: mockProductRepository,
        categoryRepository: mockCategoryRepository,
      },
      input,
    );
 
    // Assert
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.data.id).toBe('product-123');
    }
    expect(mockCategoryRepository.getById).toHaveBeenCalledWith({
      id: 'cat-1',
    });
    expect(mockProductRepository.create).toHaveBeenCalledWith({
      userId: 'user-123',
      productData: input.productData,
    });
  });
 
  it('should handle invalid price', async () => {
    // Arrange
    const mockRepositories = createMockRepositories();
    
    const input = {
      userId: 'user-123',
      productData: {
        name: 'Test Product',
        price: { amount: -100, currency: 'USD' }, // Invalid price
        categoryId: 'cat-1',
        inventory: 10,
        imageUrls: [],
      },
    };
 
    // Act
    const result = await createProductUseCase(mockRepositories, input);
 
    // Assert
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error.code).toBe('PRODUCT_INVALID_PRICE_ERROR');
    }
  });
 
  it('should handle category not found', async () => {
    // Arrange
    const mockRepositories = {
      ...createMockRepositories(),
      categoryRepository: {
        getById: jest.fn().mockResolvedValue(err({
          code: 'CATEGORY_NOT_FOUND_ERROR',
          message: 'Category not found',
        })),
      },
    };
 
    // Act
    const result = await createProductUseCase(mockRepositories, validInput);
 
    // Assert
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error.code).toBe('CATEGORY_NOT_FOUND_ERROR');
    }
  });
});

Integration Testing

describe('Product Integration', () => {
  beforeEach(async () => {
    await setupTestDatabase();
  });
 
  afterEach(async () => {
    await cleanupTestDatabase();
  });
 
  it('should create and retrieve product', async () => {
    // Use real repository implementations with test database
    const repositories = createTestInfrastructure();
    
    // Create a category first
    const categoryResult = await createCategoryUseCase(
      repositories,
      {
        userId: 'user-123',
        categoryData: { name: 'Electronics', slug: 'electronics' },
      },
    );
    
    expect(categoryResult.ok).toBe(true);
    
    if (categoryResult.ok) {
      // Create a product
      const createResult = await createProductUseCase(
        repositories,
        {
          userId: 'user-123',
          productData: {
            name: 'Test Product',
            description: 'A test product',
            price: { amount: 9999, currency: 'USD' },
            categoryId: categoryResult.data.id,
            inventory: 10,
            imageUrls: ['http://example.com/image.jpg'],
          },
        },
      );
      
      expect(createResult.ok).toBe(true);
      
      if (createResult.ok) {
        // Retrieve the product
        const getResult = await getProductByIdUseCase(
          repositories,
          { id: createResult.data.id },
        );
        
        expect(getResult.ok).toBe(true);
        if (getResult.ok) {
          expect(getResult.data.name).toBe('Test Product');
          expect(getResult.data.category.name).toBe('Electronics');
        }
      }
    }
  });
 
  it('should handle complete order flow', async () => {
    const repositories = createTestInfrastructure();
    
    // Setup test data
    const { product, user } = await setupTestData(repositories);
    
    // Create order
    const orderResult = await createOrderUseCase(
      repositories,
      {
        userId: user.id,
        items: [{ productId: product.id, quantity: 2 }],
        shippingAddress: mockAddress,
        paymentMethodId: 'pm_test_123',
      },
    );
    
    expect(orderResult.ok).toBe(true);
    
    if (orderResult.ok) {
      // Verify inventory was updated
      const updatedProductResult = await getProductByIdUseCase(
        repositories,
        { id: product.id },
      );
      
      expect(updatedProductResult.ok).toBe(true);
      if (updatedProductResult.ok) {
        expect(updatedProductResult.data.inventory).toBe(8); // 10 - 2
      }
    }
  });
});

Real-World Examples

Complex Business Logic: Order Creation with Inventory Management

export const createOrderUseCase = async (
  deps: {
    orderRepository: OrderRepository;
    productRepository: ProductRepository;
    userRepository: UserRepository;
    paymentService: PaymentService;
    emailService: EmailService;
    logger: Logger;
  },
  input: {
    userId: string;
    items: OrderItemInput[];
    shippingAddress: Address;
    paymentMethodId: string;
  },
): AsyncResult<{ orderId: string }, OrderError | ProductError | PaymentError | UserError> => {
  // 1. Validate user exists and is authorized
  const userResult = await deps.userRepository.getById({
    id: input.userId,
  });
 
  if (!userResult.ok) {
    deps.logger.error('User not found for order creation', {
      userId: input.userId,
      error: userResult.error,
    });
    return err(userResult.error);
  }
 
  if (userResult.data.status === 'SUSPENDED') {
    return err({
      code: 'USER_UNAUTHORIZED_ERROR',
      message: 'Cannot create order for suspended user',
    });
  }
 
  // 2. Validate all products and check inventory
  const productValidations = await Promise.all(
    input.items.map(async (item) => {
      const productResult = await deps.productRepository.getById({
        id: item.productId,
      });
 
      if (!productResult.ok) {
        return productResult;
      }
 
      if (productResult.data.status !== 'ACTIVE') {
        return err({
          code: 'PRODUCT_NOT_AVAILABLE_ERROR',
          message: `Product ${item.productId} is not available`,
        });
      }
 
      if (productResult.data.inventory < item.quantity) {
        return err({
          code: 'PRODUCT_INSUFFICIENT_INVENTORY_ERROR',
          message: `Insufficient inventory for product ${item.productId}`,
        });
      }
 
      return ok(productResult.data);
    }),
  );
 
  // Check if any product validation failed
  const failedValidation = productValidations.find((result) => !result.ok);
  if (failedValidation && !failedValidation.ok) {
    return err(failedValidation.error);
  }
 
  // 3. Calculate total amount and apply business rules
  const products = productValidations.map((result) => result.data!);
  let totalAmount = input.items.reduce((sum, item, index) => {
    return sum + (products[index].price.amount * item.quantity);
  }, 0);
 
  // Apply discount for bulk orders
  if (totalAmount > 10000) { // $100 minimum for 10% discount
    totalAmount = Math.floor(totalAmount * 0.9);
  }
 
  // 4. Reserve inventory (optimistic locking)
  const reservationResults = await Promise.all(
    input.items.map((item) =>
      deps.productRepository.updateInventory({
        id: item.productId,
        quantity: -item.quantity,
      }),
    ),
  );
 
  const failedReservation = reservationResults.find((result) => !result.ok);
  if (failedReservation && !failedReservation.ok) {
    deps.logger.error('Failed to reserve inventory', {
      error: failedReservation.error,
    });
    return err({
      code: 'ORDER_INVENTORY_RESERVATION_ERROR',
      message: 'Failed to reserve product inventory',
    });
  }
 
  try {
    // 5. Process payment
    const paymentResult = await deps.paymentService.processPayment({
      amount: { amount: totalAmount, currency: 'USD' },
      paymentMethodId: input.paymentMethodId,
      customerId: input.userId,
      metadata: {
        orderId: 'pending',
        itemCount: input.items.length.toString(),
      },
    });
 
    if (!paymentResult.ok) {
      // Rollback inventory reservation
      await Promise.all(
        input.items.map((item) =>
          deps.productRepository.updateInventory({
            id: item.productId,
            quantity: item.quantity, // Restore inventory
          }),
        ),
      );
      
      return err(paymentResult.error);
    }
 
    // 6. Create the order
    const orderResult = await deps.orderRepository.create({
      userId: input.userId,
      items: input.items,
      shippingAddress: input.shippingAddress,
      totalAmount: { amount: totalAmount, currency: 'USD' },
      paymentId: paymentResult.data.paymentId,
      status: 'CONFIRMED',
    });
 
    if (!orderResult.ok) {
      // Rollback payment and inventory
      await deps.paymentService.refundPayment({
        paymentId: paymentResult.data.paymentId,
      });
      
      await Promise.all(
        input.items.map((item) =>
          deps.productRepository.updateInventory({
            id: item.productId,
            quantity: item.quantity,
          }),
        ),
      );
      
      return err(orderResult.error);
    }
 
    // 7. Send confirmation email
    await deps.emailService.sendOrderConfirmation({
      email: userResult.data.email,
      orderId: orderResult.data.id,
      items: input.items.map((item, index) => ({
        productName: products[index].name,
        quantity: item.quantity,
        price: products[index].price,
      })),
      totalAmount: { amount: totalAmount, currency: 'USD' },
    });
 
    deps.logger.info('Order created successfully', {
      orderId: orderResult.data.id,
      userId: input.userId,
      totalAmount,
    });
 
    return ok({ orderId: orderResult.data.id });
  } catch (error) {
    // Rollback inventory reservation on unexpected error
    await Promise.all(
      input.items.map((item) =>
        deps.productRepository.updateInventory({
          id: item.productId,
          quantity: item.quantity,
        }),
      ),
    );
 
    deps.logger.error('Unexpected error during order creation', {
      error,
      userId: input.userId,
    });
 
    return err({
      code: 'ORDER_CREATE_ERROR',
      message: 'Unexpected error during order creation',
    });
  }
};

Advanced Error Handling

export const processPaymentUseCase = async (
  deps: {
    paymentRepository: PaymentRepository;
    pledgeRepository: PledgeRepository;
    emailService: EmailService;
  },
  input: {
    pledgeToken: string;
    paymentMethodId: string;
  },
): AsyncResult<{ orderId: string }, PaymentError | PledgeError> => {
  try {
    // 1. Get pledge details
    const pledgeResult = await deps.pledgeRepository.getByToken({
      token: input.pledgeToken,
    });
 
    if (!pledgeResult.ok) {
      return err(pledgeResult.error);
    }
 
    // 2. Process payment
    const paymentResult = await deps.paymentRepository.processPayment({
      amount: pledgeResult.data.totalAmount,
      paymentMethodId: input.paymentMethodId,
    });
 
    if (!paymentResult.ok) {
      // Send failure notification
      await deps.emailService.sendPaymentFailureEmail({
        email: pledgeResult.data.backerEmail,
        reason: paymentResult.error.message,
      });
      
      return err(paymentResult.error);
    }
 
    // 3. Complete pledge
    const completeResult = await deps.pledgeRepository.completeCheckout({
      token: input.pledgeToken,
    });
 
    if (!completeResult.ok) {
      // Refund payment if pledge completion fails
      await deps.paymentRepository.refundPayment({
        paymentId: paymentResult.data.paymentId,
      });
      
      return err(completeResult.error);
    }
 
    // 4. Send success notification
    await deps.emailService.sendPaymentSuccessEmail({
      email: pledgeResult.data.backerEmail,
      orderId: completeResult.data.orderId,
    });
 
    return ok({ orderId: completeResult.data.orderId });
  } catch (error) {
    return err({
      code: 'PAYMENT_PROCESSING_ERROR',
      message: 'Unexpected error during payment processing',
    });
  }
};

Conclusion

Domain-Driven Design with Next.js provides a robust architecture for building maintainable, scalable web applications. The key benefits include:

  • Clear Separation: Business logic separate from UI concerns
  • Type Safety: Full TypeScript support across all layers
  • Testability: Easy to unit test business logic
  • Flexibility: Easy to swap implementations
  • Error Handling: Consistent, type-safe error management

By following the patterns and practices outlined in this guide, you can build applications that are not only functional but also maintainable and extensible as your business grows.

Additional Resources