Loading...

Crafting Robust MERN Applications: A Guide to Modern Software Architecture with Todo App Example

In the world of software development, good architecture isn't a luxury — it's a necessity. As applications grow in complexity, having a solid architectural foundation becomes the difference between a maintainable, scalable codebase and a tangled mess that's painful to work with.

In this comprehensive guide, we'll explore how to structure your applications using proven design patterns and architectural principles. We'll use a Todo application built with Node.js, React, and TypeScript as our running example to demonstrate these concepts in practice.

Read this page to learn:

  • Which responsibilities belong in which folder
  • How to separate concerns into testable units
  • Which patterns to use (and when) with concrete examples
  • Practical coding standards and a quick checklist to follow

The aim is pragmatic: start with a small, well-organized structure and evolve it as needs grow. Throughout the article you'll find short, actionable recommendations you can copy into your projects.

For reference, you can view the full Todo app on GitHub.


Why Architecture Matters

Before diving into specifics, let's clarify why architecture is crucial:

  • Maintainability — Well-structured code is easier to understand, modify, and debug.
  • Scalability — Good architecture allows your application to grow without becoming unmanageable.
  • Testability — Proper separation of concerns makes testing straightforward.
  • Team Collaboration — Clear structure helps multiple developers work effectively together.
  • Long-term Viability — Applications with good architecture have longer lifespans and lower maintenance costs.

Architectural Patterns & Principles

Clean Architecture

Inspired by Robert C. Martin's Clean Architecture, this approach emphasizes separation of concerns and dependency inversion. The main idea is to organize code in concentric layers with dependencies pointing inward: domain, application, infrastructure, and interfaces. This keeps your core business rules isolated from frameworks and external services. see the GeeksforGeeks overview: GeeksforGeeks — Complete Guide to Clean Architecture.

  • Independent of frameworks
  • Testable
  • Independent of UI
  • Independent of database
  • Independent of any external agency

Repository Pattern

This pattern abstracts data access, allowing you to switch data sources without affecting your business logic. The application interacts with repository interfaces, not database-specific APIs.

Use Case Pattern

Each business operation is encapsulated in a dedicated class or function (a "use case") with a single entry method (commonly execute()). This provides clear boundaries for application functionality, making it easy to test and reason about.

Research‑backed findings & practical takeaways

Over the last decade of industry writing and case studies, several consistent outcomes appear when teams adopt the separation-of-concerns patterns described above:

  • Smaller modules and explicit interfaces reduce cognitive load and speed onboarding.
  • Isolating side-effects (DB, network) into repositories and adapters makes business logic easier to test and reason about.
  • Keeping controllers thin and putting rules into use-cases reduces accidental coupling with web frameworks and simplifies porting to new transports.
  • Early validation at the boundary (Zod/Joi) greatly reduces the classes of bugs that reach the core domain.

Practical takeaway: when in doubt, extract a tiny interface and a focused test for the behavior you want to preserve. Prefer small, well-named modules over one large file that tries to do everything.


File Structure for a Well-Architected Application

Below is an ideal folder structure for a full-stack MERN application. It separates concerns into layers so each part of the system has a clear responsibility.

src/
├── domain/           # Enterprise-wide business rules
│   ├── entities/     # Business objects
│   └── types/        # Type definitions
├── application/      # Application-specific business rules
│   ├── use-cases/    # Application operations
│   └── services/     # Domain services
├── infrastructure/   # Framework & driver details
│   ├── database/     # DB connections, models, repos
│   ├── auth/         # Authentication strategies
│   └── logging/      # Logging implementation
└── interfaces/       # Adapters for external systems
    ├── http/         # Web controllers, routes, middleware
    ├── react/        # React components & hooks
    └── cli/          # Command-line interface

The goal here is repeatability: a developer who understands one module can quickly grasp others because the pattern repeats consistently across features.


How This Applies to the Todo App (Concrete Example)

Here is how the Todo application maps to the recommended structure.

For reference, you can view the full Todo app on GitHub.

todo-app/ ├── backend/ │ ├── src/ │ │ ├── domain/ │ │ │ ├── entities/ │ │ │ │ ├── Todo.ts │ │ │ │ └── User.ts │ │ │ └── types/ │ │ ├── application/ │ │ │ ├── use-cases/ │ │ │ │ ├── CreateTodo.ts │ │ │ │ ├── UpdateTodo.ts │ │ │ │ ├── DeleteTodo.ts │ │ │ │ └── GetTodos.ts │ │ │ └── services/ │ │ │ └── AuthService.ts │ │ ├── infrastructure/ │ │ │ ├── database/ │ │ │ │ ├── connection.ts │ │ │ │ ├── models/ │ │ │ │ │ ├── TodoModel.ts │ │ │ │ │ └── UserModel.ts │ │ │ │ └── repositories/ │ │ │ │ ├── TodoRepository.ts │ │ │ │ └── UserRepository.ts │ │ │ ├── auth/ │ │ │ │ ├── jwt.ts │ │ │ │ └── middleware.ts │ │ │ └── logging/ │ │ │ └── logger.ts │ │ └── interfaces/ │ │ ├── http/ │ │ │ ├── controllers/ │ │ │ │ ├── TodoController.ts │ │ │ │ └── AuthController.ts │ │ │ ├── routes/ │ │ │ │ ├── todo.ts │ │ │ │ └── auth.ts │ │ │ └── middleware/ │ │ │ ├── validation.ts │ │ │ └── requireAuth.ts │ │ └── cli/ │ │ └── commands.ts │ ├── package.json │ └── tsconfig.json └── frontend/ ├── src/ │ ├── domain/ │ │ └── types/ │ ├── application/ │ │ └── services/ │ ├── infrastructure/ │ │ └── http/ │ │ └── apiClient.ts │ └── interfaces/ │ └── react/ │ ├── components/ │ │ ├── TodoForm.tsx │ │ ├── TodoList.tsx │ │ └── TodoItem.tsx │ ├── hooks/ │ │ ├── useAuth.ts │ │ └── useTodos.ts │ ├── pages/ │ │ ├── Login.tsx │ │ └── Dashboard.tsx │ └── stores/ │ └── authStore.ts

Deep Dive: Analyzing the Todo App Architecture

Let’s step through how the example implements the architectural principles we’ve discussed.

For reference, you can view the full Todo app on GitHub.

Backend — Domain Layer

The domain layer holds business entities and types. Entities are small, focused, and devoid of framework or persistence code.

// domain/entities/Todo.ts export interface Todo { id?: string; _id?: string; title: string; completed: boolean; createdAt?: Date; updatedAt?: Date; } // domain/entities/User.ts export interface User { id?: string; _id?: string; email: string; name: string; password: string; createdAt?: Date; }

Backend — Application Layer (Use Cases)

Use cases encapsulate business operations. They accept inputs (DTOs or primitives), perform domain logic, and interact with repositories or domain services.

// application/use-cases/CreateTodo.ts
export class CreateTodo {
  constructor(private todoRepository: ITodoRepository) {}

  async execute(title: string, userId: string): Promise<Todo> {
    if (!title.trim()) {
      throw new Error('Title cannot be empty');
    }
    
    const todo = await this.todoRepository.create({
      title,
      completed: false,
      userId
    });
    
    return todo;
  }
}

Backend — Infrastructure (Repository Implementation)

Repositories wrap persistence technology (Mongoose models in this case). The rest of the app depends only on repository interfaces, not Mongoose directly.

// infrastructure/database/repositories/TodoRepository.ts
export class TodoRepository implements ITodoRepository {
  constructor(private todoModel: Model<ITodo>) {}
  
  async create(todoData: Partial<Todo>): Promise<Todo> {
    const todo = new this.todoModel(todoData);
    return await todo.save();
  }
  
  async findById(id: string): Promise<Todo | null> {
    return this.todoModel.findById(id);
  }
  
  // Other data access methods...
}

Backend — Interface Layer (HTTP Controllers)

Controllers translate HTTP requests into use-case inputs and send responses. They remain thin and delegate business logic to the application layer.

// interfaces/http/controllers/TodoController.ts
export class TodoController {
  constructor(
    private createTodo: CreateTodo,
    private updateTodo: UpdateTodo,
    private deleteTodo: DeleteTodo,
    private getTodos: GetTodos
  ) {}
  
  async create(req: Request, res: Response) {
    try {
      const { title } = req.body;
      const userId = req.user.id;
      
      const todo = await this.createTodo.execute(title, userId);
      res.status(201).json(todo);
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  }
  
  // Other controller methods...
}

Frontend Architecture

The frontend mirrors these separation-of-concerns ideas. Domain types and application services live apart from React components. Components consume services via hooks, keeping UI concerns isolated from business logic.

// application/services/todoService.ts
export class TodoService {
  constructor(private apiClient: ApiClient) {}
  
  async getTodos(): Promise<Todo[]> {
    return this.apiClient.get('/todos');
  }
  
  async createTodo(title: string): Promise<Todo> {
    return this.apiClient.post('/todos', { title });
  }
  
  async updateTodo(id: string, updates: Partial<Todo>): Promise<Todo> {
    return this.apiClient.patch(`/todos/${id}`, updates);
  }
  
  async deleteTodo(id: string): Promise<void> {
    return this.apiClient.delete(`/todos/${id}`);
  }
}// interfaces/react/hooks/useTodos.ts
export const useTodos = () => {
  const [items, setItems] = useState<Todo[]>([]);
  const [loading, setLoading] = useState(true);
  const todoService = useTodoService();
  
  useEffect(() => {
    loadTodos();
  }, []);
  
  const loadTodos = async () => {
    try {
      const todos = await todoService.getTodos();
      setItems(todos);
    } catch (error) {
      console.error('Failed to load todos:', error);
    } finally {
      setLoading(false);
    }
  };
  
  const add = async (title: string) => {
    const todo = await todoService.createTodo(title);
    setItems(prev => [todo, ...prev]);
  };
  
  const toggle = async (id: string, completed: boolean) => {
    const updated = await todoService.updateTodo(id, { completed });
    setItems(prev => prev.map(t => (t.id === id || t._id === id ? updated : t)));
  };
  
  const removeItem = async (id: string) => {
    await todoService.deleteTodo(id);
    setItems(prev => prev.filter(t => t.id !== id && t._id !== id));
  };
  
  return {
    items,
    loading,
    add,
    toggle,
    removeItem
  };
};

Coding standards — concrete rules you can copy

Small, concrete rules reduce debates and improve code quality. Copy these into your team guide or lint rules.

  • TypeScript: enable "strict" and prefer explicit return types on public functions.
  • Files: keep ~200 lines max and one default export per file where it makes sense.
  • Errors: throw domain-specific errors (BadRequestError, NotFoundError) and map them to HTTP responses in one place.
  • Naming: use kebab-case for files and folders, PascalCase for types/classes, camelCase for functions.
  • Formatting: run Prettier on save and disallow console.log in production code (use structured logger).

These rules are intentionally opinionated — pick what fits your team and be consistent.


Key Design Patterns in Action

1. Dependency Injection

The app uses simple wiring (a "composition root") where repositories are created and passed into use-cases, which are then passed into controllers. This decouples module construction from application logic.

2. Repository Pattern

Repositories define an interface for data operations. Use-cases depend on these interfaces instead of concrete database implementations.

3. Composition Over Inheritance

React components are composed from small, focused components rather than using inheritance, promoting reuse and testability.


Validation at the Edge

Use Zod (or similar) to validate requests at the HTTP boundary. That keeps domain code free of parsing concerns and allows clear error responses early.

// interfaces/http/middleware/validation.ts
import { z } from 'zod';

export const validate = (schema: z.ZodSchema) => {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse(req.body);
      next();
    } catch (error) {
      res.status(400).json({ error: 'Invalid input', details: error.errors });
    }
  };
};

// Todo creation schema
export const createTodoSchema = z.object({
  title: z.string().min(1).max(255),
});

Security Considerations

The application follows core security best practices:

  • JWT Authentication: secure token-based authentication with sensible expirations.
  • Password Hashing: async bcrypt hashing to avoid blocking the event loop.
  • Input Validation at the edge to avoid injection and malformed data.
  • HTTP Security Headers via Helmet.js.
  • Careful CORS configuration to restrict origins.
// infrastructure/auth/jwt.ts
export const generateToken = (userId: string): string => {
  return jwt.sign({ userId }, process.env.JWT_SECRET!, {
    expiresIn: process.env.JWT_EXPIRES_IN || '7d',
  });
};

export const verifyToken = (token: string): { userId: string } => {
  return jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
};

Testing Strategy

A well-architected system is inherently testable. Recommended approach:

  • Unit: Test use-cases with in-memory or mock repositories to get fast feedback.
  • Integration: Test Express routes with Supertest covering schemas, controllers and real repos or test DB.
  • UI: Use React Testing Library for component behavior and MSW for API mocking.
// tests/application/use-cases/CreateTodo.spec.ts
describe('CreateTodo', () => {
  let createTodo: CreateTodo;
  let mockTodoRepository: jest.Mocked<ITodoRepository>;
  beforeEach(() => {
    mockTodoRepository = { create: jest.fn(), findById: jest.fn(), findByUser: jest.fn(), update: jest.fn(), delete: jest.fn() };
    createTodo = new CreateTodo(mockTodoRepository);
  });

  it('should create a todo with valid title', async () => {
    const todoData = { id: '1', title: 'Test todo', completed: false, userId: 'user1' };
    mockTodoRepository.create.mockResolvedValue(todoData);
    const result = await createTodo.execute('Test todo', 'user1');
    expect(mockTodoRepository.create).toHaveBeenCalledWith({ title: 'Test todo', completed: false, userId: 'user1' });
    expect(result).toEqual(todoData);
  });

  it('should throw error for empty title', async () => {
    await expect(createTodo.execute('', 'user1')).rejects.toThrow('Title cannot be empty');
    await expect(createTodo.execute('   ', 'user1')).rejects.toThrow('Title cannot be empty');
  });
});

Development Experience (DX) Improvements

  • TypeScript strict mode to catch issues early.
  • ESLint + Prettier and EditorConfig for consistent formatting.
  • Structured logging (pino) and health endpoints for observability.
  • CI checks: type-check, lint, test on PRs to enforce quality.
  • Local developer scripts (seed, reset, start) to reduce onboarding friction.

Migration to More Advanced Patterns (When Needed)

As systems grow, you may opt for:

  • Dependency Injection container (Awilix, TypeDI) for more structured wiring.
  • CQRS to separate read/write concerns for complex domains.
  • Event sourcing for an auditable sequence of changes.
  • Microservices to split bounded contexts when a monolith becomes too large.
  • GraphQL as a complementary API for flexible client queries.

Conclusion

Building applications with good architecture isn’t about blindly following patterns — it’s about understanding the principles behind them and applying them judiciously. The Todo application examined here demonstrates how to:

  • Separate concerns into distinct layers
  • Apply dependency inversion to make code testable and flexible
  • Use patterns like Repository and Use Case effectively
  • Implement security best practices
  • Create a maintainable and scalable codebase

Quick architecture checklist

  • Can core business logic be tested without the DB?
  • Are controllers thin and free of domain rules?
  • Do repositories hide DB details behind an interface?
  • Are validation and auth enforced at the boundary?
  • Does the repo layout repeat the same pattern per feature?

Architecture should serve your application’s needs — start with a simple structure and evolve it as requirements change. Maintain clear boundaries and prefer abstractions over concrete implementations to keep your codebase adaptable and enjoyable to work with.


Additional Resources

  • Patterns.dev — Modern React patterns and recommendations
  • Node.js Design Patterns —Official Site, nodejs design pattern GitHub Repo
  • Platformatic Node Principles — Practical Node.js best practices
  • Clean Architecture — Robert C. Martin’s original writing on the subject.