DEV Community

Cover image for Clean Architecture Demystified: A Practical Guide for Software Developers
Ben Borla
Ben Borla

Posted on

Clean Architecture Demystified: A Practical Guide for Software Developers

As developers, we've all encountered codebases that feel like navigating a maze blindfolded. Clean Architecture, proposed by Robert C. Martin (Uncle Bob), offers a way out of this chaos. Let's break it down in practical terms using NextJS, TypeScript, DrizzleORM, and PostgreSQL.

Why Code Structure Matters

Imagine you're working on an e-commerce app built with NextJS. Here's how it might look with poor structure vs. Clean Architecture:

Poor Structure:

// pages/api/products/[id].ts
import { NextApiRequest, NextApiResponse } from 'next';
import { drizzle } from 'drizzle-orm/node-postgres';
import { eq } from 'drizzle-orm';
import { Pool } from 'pg';
import { products } from '@/schema';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

const db = drizzle(pool);

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { id } = req.query;

  const [product] = await db.select().from(products).where(eq(products.id, id as string));

  if (product) {
    res.status(200).json(product);
  } else {
    res.status(404).json({ message: 'Product not found' });
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case, changing from PostgreSQL to another database would require rewriting every API route that interacts with the database.

Clean Architecture:

// entities/Product.ts
export interface Product {
  id: string;
  name: string;
  price: number;
}

// use-cases/GetProduct.ts
export interface ProductRepository {
  getProduct(id: string): Promise<Product | null>;
}

export class GetProduct {
  constructor(private repository: ProductRepository) {}

  async execute(id: string): Promise<Product | null> {
    return this.repository.getProduct(id);
  }
}

// infrastructure/DrizzleProductRepository.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { eq } from 'drizzle-orm';
import { Pool } from 'pg';
import { products } from '@/schema';
import { Product, ProductRepository } from '@/use-cases/GetProduct';

export class DrizzleProductRepository implements ProductRepository {
  private db;

  constructor() {
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
    this.db = drizzle(pool);
  }

  async getProduct(id: string): Promise<Product | null> {
    const [product] = await this.db.select().from(products).where(eq(products.id, id));

    if (product) {
      return {
        id: product.id,
        name: product.name,
        price: product.price,
      };
    }
    return null;
  }
}

// pages/api/products/[id].ts
import { NextApiRequest, NextApiResponse } from 'next';
import { GetProduct } from '@/use-cases/GetProduct';
import { DrizzleProductRepository } from '@/infrastructure/DrizzleProductRepository';

const getProduct = new GetProduct(new DrizzleProductRepository());

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { id } = req.query;
  const product = await getProduct.execute(id as string);

  if (product) {
    res.status(200).json(product);
  } else {
    res.status(404).json({ message: 'Product not found' });
  }
}
Enter fullscreen mode Exit fullscreen mode

With this structure, swapping databases only requires creating a new repository implementation. The use case and API route don't need to change at all. That's the power of Clean Architecture - it isolates the impact of changes to specific parts of your system.

Clean Architecture: The Layer Cake

Picture your NextJS app as a layer cake:

  1. Entities (Core): These are your business objects. In our e-commerce app, think Product, Order, and Customer.
  2. Use Cases (Application Business Rules): These orchestrate data flow between entities and the outer layers. Example: GetProduct, PlaceOrder.
  3. Interface Adapters: They convert data between use cases and external agencies. In NextJS, this includes your API routes and controllers.
  4. Frameworks and Drivers (Outermost layer): This is where NextJS, DrizzleORM, PostgreSQL, and UI components live.

Key Principles in Action

1. The Dependency Rule

Inner layers should not know about outer layers. This means your Product entity shouldn't know about your database or NextJS.

// Good: Entity doesn't know about storage or framework
export interface Product {
  id: string;
  name: string;
  price: number;
}

// Bad: Entity depends on a database
import { InferModel } from 'drizzle-orm';
import { products } from '@/schema';

type Product = InferModel<typeof products>;

export interface Product {
  save(): Promise<void>;  // This violates Clean Architecture
}
Enter fullscreen mode Exit fullscreen mode

2. Use Cases Define Application Behavior

Use cases encapsulate and implement all of the use cases of the system.

export interface ProductRepository {
  getProduct(id: string): Promise<Product | null>;
  updateProduct(id: string, data: Partial<Product>): Promise<void>;
}

export class UpdateProduct {
  constructor(private repository: ProductRepository) {}

  async execute(id: string, data: Partial<Product>): Promise<void> {
    const product = await this.repository.getProduct(id);
    if (!product) {
      throw new Error('Product not found');
    }
    await this.repository.updateProduct(id, data);
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Interface Adapters

In NextJS, your API routes act as interface adapters. They convert incoming HTTP requests into calls to your use cases and convert the results back into HTTP responses.

// pages/api/products/[id].ts
import { NextApiRequest, NextApiResponse } from 'next';
import { UpdateProduct } from '@/use-cases/UpdateProduct';
import { DrizzleProductRepository } from '@/infrastructure/DrizzleProductRepository';

const updateProduct = new UpdateProduct(new DrizzleProductRepository());

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'PUT') {
    const { id } = req.query;
    const data = req.body;

    try {
      await updateProduct.execute(id as string, data);
      res.status(200).json({ message: 'Product updated successfully' });
    } catch (error) {
      res.status(404).json({ message: error.message });
    }
  } else {
    res.setHeader('Allow', ['PUT']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Frameworks and Drivers

This layer is where all the details go. Your NextJS configuration, DrizzleORM setup, database connection, and React components live here.

// infrastructure/DrizzleProductRepository.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { eq } from 'drizzle-orm';
import { Pool } from 'pg';
import { products } from '@/schema';
import { Product, ProductRepository } from '@/use-cases/UpdateProduct';

export class DrizzleProductRepository implements ProductRepository {
  private db;

  constructor() {
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
    this.db = drizzle(pool);
  }

  async getProduct(id: string): Promise<Product | null> {
    const [product] = await this.db.select().from(products).where(eq(products.id, id));
    return product ? { id: product.id, name: product.name, price: product.price } : null;
  }

  async updateProduct(id: string, data: Partial<Product>): Promise<void> {
    await this.db.update(products).set(data).where(eq(products.id, id));
  }
}

// components/ProductForm.tsx
import React, { useState } from 'react';
import { useRouter } from 'next/router';

export function ProductForm({ product }) {
  const [name, setName] = useState(product.name);
  const [price, setPrice] = useState(product.price);
  const router = useRouter();

  const handleSubmit = async (e) => {
    e.preventDefault();
    const res = await fetch(`/api/products/${product.id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, price }),
    });
    if (res.ok) {
      router.push(`/products/${product.id}`);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Real-World Benefits

  1. Testability: You can test business rules without UI, database, or any external element.
  2. Independence: You can easily swap out your database or even migrate from NextJS to another framework with minimal changes to your core business logic.
  3. Maintainability: New developers can easily understand and navigate the system.

Is It Always Necessary?

For a simple CRUD app, Clean Architecture might be overkill. But it's a game-changer for complex applications where business rules are crucial. It's the difference between dreading and embracing changes to your codebase.

Conclusion

Clean Architecture isn't just theoretical—it's a practical approach to building maintainable, flexible software with NextJS, even when using modern ORMs like DrizzleORM. By separating concerns and adhering to the dependency rule, you create a codebase that's resilient to change and a joy to work with.

Next time you start a NextJS project, consider how Clean Architecture principles could make your future self (and your team) thank you. Your code will be cleaner, your architecture is more robust, and your development process smoother.

As you implement Clean Architecture in your NextJS projects, remember that the examples provided in this guide are starting points. The beauty of Clean Architecture lies in its flexibility within its core principles. Feel free to iterate and refine these patterns to best suit your project's needs while maintaining the separation of concerns and dependency rules that make Clean Architecture so powerful.

Bonus: Clean Architecture Project Structure for NextJS with DrizzleORM

Here's a sample project structure that follows Clean Architecture principles:

nextjs-app/
├── src/
│   ├── entities/
│   │   └── Product.ts
│   ├── use-cases/
│   │   ├── GetProduct.ts
│   │   └── UpdateProduct.ts
│   ├── interfaces/
│   │   └── repositories/
│   │       └── ProductRepository.ts
│   ├── infrastructure/
│   │   ├── DrizzleProductRepository.ts
│   │   └── schema.ts
│   ├── pages/
│   │   ├── api/
│   │   │   └── products/
│   │   │       └── [id].ts
│   │   ├── products/
│   │   │   ├── [id].tsx
│   │   │   └── edit/[id].tsx
│   │   └── index.tsx
│   └── components/
│       └── ProductForm.tsx
├── public/
├── tests/
│   ├── unit/
│   ├── integration/
│   └── e2e/
├── package.json
├── tsconfig.json
└── next.config.js
Enter fullscreen mode Exit fullscreen mode

Note: This project structure is a suggested template based on Clean Architecture principles. You may adjust it to fit your specific project needs, development practices, and team preferences. The key is to maintain a clear separation between the layers of your application, ensuring that dependencies point inwards towards the core business logic.

Happy coding, and happy Monday!


Reference:
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

Top comments (0)