Logo
Architecture

Modern Architecture and Design Patterns: My Approach as a Full Stack Developer

Explore the architectural and design patterns that really work in modern web development. I show you how I choose between MVC, microservices, and distributed architectures based on my practical experience.

Jorge Reyes

Jorge Reyes

Full Stack Developer

05/27/2025
12 min read
Technical article
ArchitectureDesign PatternsTypeScriptMicroservicesMVCBFFFull Stack
Modern Architecture and Design Patterns: My Approach as a Full Stack Developer

Modern Architecture and Design Patterns: My Approach as a Full Stack Developer

As a full stack developer specializing in React/Next.js and Node.js, I’ve learned that the difference between a maintainable system and one that turns into a technical nightmare often comes down to the architectural decisions made from the very beginning. Design and architecture patterns aren’t just theoretical concepts—they’re practical tools that, when applied correctly, define the quality, scalability, and clarity of a project.

In this article, I share the patterns that have truly worked for me within the modern JavaScript and TypeScript ecosystem. It’s not about following trends, but about applying solutions that solve real problems with elegance and efficiency.

Design Patterns: Tools for Specific Problems

Creational Patterns: Controlling Object Creation

Singleton: My Go-To for Global Connections

The Singleton pattern is built on a simple yet powerful idea: ensure that a class has only one instance and provide a global point of access to it. It’s especially useful when working with shared resources that need to remain consistent across the application—like database connections, global configurations, or logging services.

Personally, I use it to make sure the database connection is initialized only once, avoiding unnecessary duplication and potential conflicts. Here’s how I implement it:

typescript
// database/DatabaseConnection.ts
export class DatabaseConnection {
  private static instance: DatabaseConnection
  private connection: any

  private constructor() {
    // Private configuration to avoid direct instantiation
    this.connection = this.initializeConnection()
  }

  public static getInstance(): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      DatabaseConnection.instance = new DatabaseConnection()
    }
    return DatabaseConnection.instance
  }

  private initializeConnection() {
    // Logic for database connection
    return {/* real connection */}
  }

  public getConnection() {
    return this.connection
  }
}

// Usage in any part of the application
const db = DatabaseConnection.getInstance()
const connection = db.getConnection()

Factory: Flexible Component Creation

The Factory pattern offers an elegant way to create objects or components dynamically, based on conditions or specific parameters. In React, it’s particularly helpful when rendering different types of components depending on props—without cluttering the main JSX with multiple conditionals.

The core idea is to encapsulate the creation logic within a function or class, which improves code organization and makes scaling easier. While I haven’t used it in production yet, the following example shows how it could be applied to generate components flexibly:

typescript
// components/ComponentFactory.tsx
import { ReactElement, PropsWithChildren } from "react"
import Button from "./Button"
import Input from "./Input"
import Card from "./Card"

export type ComponentType = "button" | "input" | "card"

export interface BaseProps {
  type: ComponentType
  [key: string]: any
}
  
type ComponentFactoryProps = PropsWithChildren

export function ComponentFactory({
  type,
  children,
  ...restProps
}: ComponentFactoryProps): React.JSX.Element {
  switch (type) {
    case "button":
      return 
    case "input":
      return 
    case "card":
      return {children}
    default:
      throw new Error(`Unknown component type: ${type}`)
  }
}

// Usage in components

  Click me

Structural Patterns: Managing Relationships Between Objects

Adapter: Integrating Incompatible APIs

The Adapter pattern is used to translate the interface of an external class or structure into one that fits our internal needs. It’s especially useful when working with third-party APIs that don’t follow the same format or conventions as our system. This pattern makes a lot of sense in projects where external services are consumed and we need to maintain consistency in our data models. The following example shows how we could adapt an external response to match our internal structure:

typescript
// adapters/ExternalApiAdapter.ts
interface ExternalUser {
  user_name: string
  user_email: string
  user_age: number
}

interface InternalUser {
  name: string
  email: string
  age: number
}

export class UserAdapter {
  static adaptExternalUser(externalUser: ExternalUser): InternalUser {
    return {
      name: externalUser.user_name,
      email: externalUser.user_email,
      age: externalUser.user_age
    }
  }

  static adaptInternalUser(internalUser: InternalUser): ExternalUser {
    return {
      user_name: internalUser.name,
      user_email: internalUser.email,
      user_age: internalUser.age
    }
  }
}

// Usage when consuming APIs externally
const externalUser: ExternalUser = await fetchExternalUser()
const adaptedUser = UserAdapter.adaptExternalUser(externalUser)

Behavioral Patterns: Managing Algorithms and Responsibilities

Proxy: Access Control and Operation Handling

The Proxy pattern acts as an intermediary between the consumer and the actual object, allowing us to intercept, modify, or control access to certain operations. It’s particularly useful when we want to add extra logic, such as caching, validation, logging, or permission control, without altering the original object.

This pattern can be applied to optimize API calls, protect sensitive resources, or even simulate responses in testing environments. Below is a basic example of how a proxy could be implemented to handle automatic caching for HTTP requests:

typescript
// proxies/ApiProxy.ts
interface ApiResponse {
  data: any
  status: number
}

export class ApiProxy {
  private cache: Map = new Map()

  constructor(private apiService: any) {}

  async get(url: string): Promise {
    if (this.cache.has(url)) {
      console.log("Returning cached response")
      return this.cache.get(url)!
    }

    const response = await this.apiService.get(url)
    this.cache.set(url, response)
    
    return response
  }
}

// Usage for caching automatic
const apiProxy = new ApiProxy(apiService)
const data = await apiProxy.get("/users") // Requests to the real API
const cachedData = await apiProxy.get("/users") // Returns from cache

Architecture Patterns: The Foundation of Every System

MVC Architecture (Model-View-Controller)

This pattern divides the application into three well-defined layers:

  • Model: Handles data and business logic
  • View: Presents information to the user
  • Controller: Acts as a bridge between the two, processing requests
  • It’s a classic approach that still proves useful in Express.js projects where views are rendered directly from the server. While I personally prefer to separate the frontend into its own project, especially when working with React or Next.js, I recognize that MVC can be practical in monolithic applications, admin systems, or MVPs where simplicity and speed of development are key.

    Here’s a basic structure that illustrates this pattern well:

    typescript
    src/
    ├── controllers/
    │   ├── UserController.ts
    ├── models/
    │   ├── UserModel.ts
    ├── views/
    │   ├── user/
    │   │   ├── profile.ejs
    ├── routes/
    │   ├── userRoutes.ts
    
    // controllers/UserController.ts
    export class UserController {
      static async getProfile(req: Request, res: Response) {
        try {
          const user = await UserModel.findById(req.params.id)
          res.render("user/profile", { user })
        } catch (error) {
          res.status(500).json({ error: "Internal server error" })
        }
      }
    }
    

    Microservices Architecture

    The microservices pattern proposes splitting an application into independent services, each with its own logic, database, and deployment cycle. This architecture allows for granular scaling, better distribution of responsibilities across teams, and improved system resilience.

    Although I haven’t implemented microservices in production yet, I tend to structure my projects in a modular way, separating functionalities into independent services that could easily evolve into microservices. This separation helps me keep the code clean, testable, and scalable.

    Here’s a basic example of how this architecture might be structured:

    typescript
    // services/auth-service/src/index.ts
    import express from "express"
    import authRoutes from "./routes/auth"
    
    const app = express()
    app.use("/auth", authRoutes)
    
    // services/user-service/src/index.ts
    import express from "express"
    import userRoutes from "./routes/users"
    
    const app = express()
    app.use("/users", userRoutes)
    

    Monolithic vs Distributed Architecture

    Choosing between a monolithic and a distributed architecture depends on the scope of the project, the size of the team, and long-term goals. In my experience, both have their place, and knowing when to apply each is a key part of architectural thinking.

    Monolith: Ideal for small projects or MVPs

    A monolithic architecture bundles the frontend, backend, and shared logic into a single repository. It’s perfect for small projects, MVPs, or environments where speed and simplicity are top priorities.

    typescript
    // Example of monolithic structure
    project/
    ├── src/
    │   ├── frontend/    // React/Next.js
    │   ├── backend/     // Express API
    │   ├── shared/      // Shared utilities
    

    This approach simplifies initial development, reduces deployment complexity, and makes it easy to share code across layers. However, as the project grows, it can become harder to maintain, scale, or split across teams.

    Distributed: My go-to for serious projects

    When a project requires scalability, team independence, or separate deployment pipelines, I prefer a distributed architecture. Splitting frontend and backend into separate repositories gives me more flexibility, independent versioning, and better control over responsibilities.

    typescript
    // Distributed structure (separate repositories)
    frontend-repo/          // React/Next.js application
    backend-repo/           // Express API
    

    This setup is especially useful in digital products with parallel development cycles, where the frontend evolves at its own pace and the backend can scale or split into more specialized services.

    Component-Based Architecture

    One of the practices I enjoy most in React is component-based architecture. It not only improves code organization, but also makes scalability, testing, and team collaboration much easier. In my projects, I take this approach to the extreme, every piece of the interface, no matter how small, has its own space, logic, and lifecycle.

    The idea is to treat each component as a self-contained unit, with its own implementation, tests, documentation, and styles. This makes it easier to reuse, version, and maintain the system over time. Here’s a structure that reflects how I typically organize my components:

    typescript
    // src/components/
    ├── ui/              // Pure UI components
    │   ├── Button/
    │   │   ├── Button.tsx
    │   │   ├── Button.test.tsx
    │   │   ├── Button.stories.tsx
    │   │   └── index.ts
    ├── forms/           // Form components
    ├── layout/          // Layout components
    └── features/        // Domain-specific components
    

    This kind of organization helps me work with clarity and speed. For example, if I need to update the main button in the system, I know exactly where its logic, tests, and visual documentation (via Storybook) are located. Separating components by domain, like forms, layout, or features, also helps maintain focus and avoid unnecessary coupling.

    Back for Frontend (BFF): My Secret Weapon for Performance

    The Back for Frontend (BFF) pattern involves creating an intermediate layer between the frontend and backend services, tailored specifically to the needs of the user interface.

    In a Next.js project where the frontend and backend lived in the same environment, I implemented this pattern using a Server Action. From there, I called my internal API without exposing endpoints directly to the client. This strategy allowed me to encapsulate logic, protect sensitive routes, and deliver data exactly as the component needed it.

    Here’s a basic example of how a BFF could be structured using Express to prevent the client from making multiple calls to different services:

    typescript
    // services/bff-gateway/src/index.ts
    import express from "express"
    import { userService, orderService, productService } from "./clients"
    
    const app = express()
    
    app.get("/user-dashboard/:userId", async (req, res) => {
      try {
        const [user, orders, recommendations] = await Promise.all([
          userService.getUser(req.params.userId),
          orderService.getUserOrders(req.params.userId),
          productService.getRecommendations(req.params.userId)
        ])
    
        res.json({
          user,
          orders,
          recommendations
        })
      } catch (error) {
        res.status(500).json({ error: "Failed to fetch dashboard data" })
      }
    })
    

    Final Thoughts: How I Choose the Right Pattern

  • I evaluate the size and type of application — some patterns work better for small projects or MVPs, while others are designed for complex, high-traffic systems.
  • The simplest pattern that solves the problem is usually the most effective and sustainable.
  • I prefer to apply a pattern consistently across the project rather than mixing approaches without a clear reason.
  • Strong typing (TypeScript) helps me implement patterns with greater safety, clarity, and confidence.
  • Every file, function, and component should have a single responsibility. This principle guides my entire architecture.
  • Bonus: My Recommended Tech Stack for Most Projects

    Over time, I’ve refined a combination of tools and approaches that help me build robust, scalable, and maintainable systems. This is the stack I typically recommend and apply, both in terms of architecture and design patterns:

  • Frontend: Next.js with a well-defined, domain-organized component architecture
  • Backend: REST API built with Express.js and TypeScript, with services separated by responsibility
  • Communication: Distributed architecture with the BFF pattern to aggregate data and simplify interaction between layers
  • Design Patterns: Singleton for managing global resources like connections or configurations