Skip to main content

What is Software Architecture? — Monolith, Layered, Clean

Introduction

Imagine building a house with no blueprint. You start by pouring a foundation, then realize the kitchen plumbing needs to run through the bedroom. You wire electricity before knowing where the windows go. Six months later, you have a structure that works — until you try to add a second bathroom and discover the pipes are entangled with load-bearing walls.

This is what happens to software without architecture. Code that starts small grows into a tangled mess where changing one feature breaks three others.

Software architecture is the blueprint that prevents this chaos. It defines how you organize code into groups, how those groups communicate, and — critically — which groups are allowed to know about which other groups.

In this tutorial, we'll explore three major architectural approaches — Monolithic, Layered, and Clean — and understand why the choice you make on day one echoes through every feature, bug fix, and team scaling decision for the life of the project.

This is the foundation for S13. In the tutorials that follow, we'll zoom into Layered Architecture (Tutorial 2), learn Dependency Injection (Tutorials 3-4), and master module separation (Tutorial 5).

Why Architecture Matters — Before We Define It

Consider a team building an online bookstore. In week one, a single Python file handles everything: receiving HTTP requests, checking inventory, calculating prices, and writing to the database. It works. The team ships fast.

By month three, the file is 4,000 lines long. Fixing a pricing bug accidentally changes the database query format. Adding a new payment method means touching the same file the inventory team is modifying. Deployments break because two developers edited the same 200-line function.

This isn't a code problem — it's an architecture problem. The team never decided:

  • Where does request handling end and business logic begin?
  • Which code is allowed to talk to the database?
  • How do we add a feature without risking existing ones?

Architecture answers these questions. It's not about choosing fancy frameworks — it's about drawing boundaries that make the system understandable, changeable, and testable.

Remember the SOLID principles from S6? Architecture is SOLID applied at the system level. Single Responsibility becomes "each layer has one job." Dependency Inversion becomes "inner layers don't depend on outer layers." Open/Closed becomes "add features by adding modules, not modifying existing ones."

What Is Software Architecture?

Software architecture is the set of high-level decisions about how a system is organized into components, how those components interact, and what constraints govern their relationships.

Think of it at three levels:

  • Structure: What are the major pieces? (e.g., controllers, services, repositories)
  • Communication: How do pieces talk to each other? (direct calls, events, messages)
  • Constraints: What rules prevent chaos? ("the UI layer must never query the database directly")

Architecture is NOT:

  • A framework (Spring, Django, FastAPI are tools — not architecture)
  • A folder structure (folders reflect architecture, but renaming folders doesn't change architecture)
  • A one-time decision (architecture evolves as requirements change)

Architecture vs Design

AspectArchitectureDesign (LLD)
ScopeSystem-wide organizationIndividual classes and methods
DecisionsWhich layers exist, how they communicateWhich pattern to use, how to model a class
Changed byMajor restructuringRefactoring within a layer
Examples"Use a layered architecture with 3 tiers""Use the Strategy Pattern for pricing"

Architecture sets the stage. Design fills in the details. Both matter — but architecture mistakes are far more expensive to fix. Changing a design pattern is a day's work. Changing from monolithic to microservices is a year-long migration.

Monolithic Architecture

A monolithic architecture deploys the entire application as a single unit. All code — UI handling, business logic, data access — lives in one codebase and runs as one process.

Real-world analogy: A monolith is like a single-story warehouse where every department (sales, inventory, shipping) works in one open floor. Everyone can reach everything — which is efficient when the company is small, but chaotic when 500 people work there.

How It Works

In a monolithic bookstore:

  • A single application handles HTTP requests, validates input, runs business logic, queries the database, and returns responses
  • All code compiles and deploys together
  • The database is shared across all features
  • Scaling means running more copies of the entire application

When Monolith Shines

  • Early-stage startups: Ship fast, iterate quickly, don't over-engineer
  • Small teams (2-5 developers): Low communication overhead, no network calls between services
  • Simple domains: If the business logic isn't complex, a monolith avoids distributed-system headaches
  • Prototyping: Prove the concept before investing in architecture

When Monolith Hurts

  • Team scaling: 20+ developers modifying the same codebase causes merge conflicts and coordination nightmares
  • Deployment coupling: A bug in the payment module blocks deployment of the catalog feature
  • Technology lock-in: The entire application must use the same language and framework
  • Selective scaling: If only the search feature needs 10x capacity, you still scale the entire application

Monoliths aren't inherently bad — many successful companies (Shopify, Stack Overflow) run on well-structured monoliths. The key word is "well-structured." A monolith with clear internal boundaries is far better than a poorly designed microservice architecture.

Layered Architecture

Layered architecture organizes code into horizontal layers, where each layer has a specific responsibility and communicates only with the layer directly below it.

Real-world analogy: Think of a restaurant. The front of house (host, waiters) interacts with customers. The kitchen (chefs) prepares food. The pantry/storage holds ingredients. A customer never walks into the storage room — they talk to the waiter, who tells the kitchen, which pulls from storage. Each layer has a clear role and a strict communication path.

The classic three-tier layered architecture:

  • Presentation Layer: Handles user interaction — REST APIs, web controllers, CLI commands. Accepts requests, validates input format, returns responses.
  • Service (Business Logic) Layer: Contains the rules. Calculates prices, enforces inventory limits, applies discounts. Knows nothing about HTTP or SQL.
  • Repository (Data Access) Layer: Talks to the database. Saves, retrieves, updates, deletes data. Knows nothing about business rules.

The Golden Rule of Layered Architecture

Dependencies flow downward only. The Presentation layer depends on the Service layer, which depends on the Repository layer. Never the reverse.

This means:

  • The Repository layer has zero knowledge of who calls it
  • The Service layer doesn't know if it's being called from a REST controller or a CLI script
  • Replacing the database (PostgreSQL → MongoDB) only changes the Repository layer

We'll dive deep into implementing this in the next tutorial. For now, understand the core principle: each layer is a boundary that prevents concerns from leaking across.

Clean Architecture

Clean Architecture (popularized by Robert C. Martin, whom we encountered in S6's SOLID principles) takes layered architecture further with a critical insight: the innermost layer should be the business domain, and nothing in the domain depends on external tools.

The layers, from innermost to outermost:

  1. Entities (Domain): Core business objects and rules. A Book entity knows that a price can't be negative. This layer has zero external dependencies — no frameworks, no databases, no HTTP.

  2. Use Cases (Application): Application-specific business rules. "Place an order" orchestrates entities but doesn't know about REST or SQL.

  3. Interface Adapters: Converts data between use cases and external systems. Controllers, presenters, repository implementations live here.

  4. Frameworks & Drivers: The outermost ring — HTTP servers, database drivers, UI frameworks. These are details, not architecture.

The Dependency Rule

The most important rule in Clean Architecture: source-code dependencies must point inward only. Nothing in an inner ring knows anything about an outer ring.

This is the Dependency Inversion Principle (S6, Tutorial 6) elevated to system architecture:

  • Entities don't import from use cases
  • Use cases don't import from interface adapters
  • Interface adapters don't import from frameworks

Why this matters: Your business rules ("a customer can't order more than 10 copies of the same book") never depend on whether you use Flask or FastAPI, PostgreSQL or MongoDB. If you swap frameworks, the domain stays untouched.

Clean vs Layered

AspectLayered ArchitectureClean Architecture
Dependency directionTop → Bottom (presentation → repository)Outside → Inside (frameworks → domain)
Center of gravityOften the database (design starts with tables)The domain (design starts with business rules)
Framework couplingModerate — framework often leaks into service layerMinimal — framework is the outermost ring
ComplexityLower — easier to understand and implementHigher — more abstractions, more files
Best forCRUD-heavy applicationsComplex domains with evolving business rules

For most LLD interview problems and team projects, layered architecture is the practical starting point. Clean architecture shines when the domain is complex enough to justify the extra abstraction.

Architecture Comparison

Comparison chart of Monolithic, Layered, and Clean Architecture showing structure, dependency direction, best use case, and trade-offs for each style
Comparison chart of Monolithic, Layered, and Clean Architecture showing structure, dependency direction, best use case, and trade-offs for each style

Visualization

Software Architecture Styles — Monolith, Layered, Clean

Code Example — Monolithic vs Layered

Let's see the difference concretely. We'll build a simple "place order" feature for our online bookstore — first as a monolith, then with layered separation.

The Monolithic Approach

Everything in one function — request parsing, business logic, and data access tangled together:

# Monolithic style: everything in one place
import sqlite3
from http.server import BaseHTTPRequestHandler
import json


class BookstoreHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path == "/orders":
            # Parse HTTP request (presentation concern)
            content_length = int(self.headers["Content-Length"])
            body = json.loads(self.rfile.read(content_length))
            book_id = body["book_id"]
            quantity = body["quantity"]

            # Query database directly (data access concern)
            conn = sqlite3.connect("bookstore.db")
            cursor = conn.cursor()
            cursor.execute("SELECT price, stock FROM books WHERE id = ?", (book_id,))
            row = cursor.fetchone()

            if row is None:
                self.send_response(404)
                self.end_headers()
                self.wfile.write(b'{"error": "Book not found"}')
                conn.close()
                return

            price, stock = row

            # Business logic mixed in with everything else
            if quantity > stock:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b'{"error": "Not enough stock"}')
                conn.close()
                return

            total = price * quantity
            if quantity >= 5:
                total *= 0.9  # 10% bulk discount

            # More database access interleaved with response
            cursor.execute(
                "INSERT INTO orders (book_id, quantity, total) VALUES (?, ?, ?)",
                (book_id, quantity, total)
            )
            cursor.execute(
                "UPDATE books SET stock = stock - ? WHERE id = ?",
                (quantity, book_id)
            )
            conn.commit()
            conn.close()

            # Format HTTP response (presentation concern again)
            self.send_response(201)
            self.end_headers()
            self.wfile.write(json.dumps({"total": total}).encode())

What's wrong here? HTTP parsing, business rules (bulk discount), and raw SQL are woven into one method. Want to change the database? You edit the handler. Want to reuse the pricing logic in a CLI tool? You can't — it's trapped inside an HTTP handler.

The Layered Approach

Same feature, but each concern lives in its own layer:

# --- Repository Layer: data access only ---
from dataclasses import dataclass
from typing import Optional


@dataclass
class Book:
    id: str
    title: str
    price: float
    stock: int


@dataclass
class Order:
    id: str
    book_id: str
    quantity: int
    total: float


class BookRepository:
    """Handles all database operations for books."""

    def __init__(self, db_connection):
        self._conn = db_connection

    def find_by_id(self, book_id: str) -> Optional[Book]:
        cursor = self._conn.cursor()
        cursor.execute(
            "SELECT id, title, price, stock FROM books WHERE id = ?",
            (book_id,)
        )
        row = cursor.fetchone()
        if row is None:
            return None
        return Book(id=row[0], title=row[1], price=row[2], stock=row[3])

    def update_stock(self, book_id: str, new_stock: int) -> None:
        cursor = self._conn.cursor()
        cursor.execute(
            "UPDATE books SET stock = ? WHERE id = ?",
            (new_stock, book_id)
        )
        self._conn.commit()


class OrderRepository:
    """Handles all database operations for orders."""

    def __init__(self, db_connection):
        self._conn = db_connection

    def save(self, order: Order) -> None:
        cursor = self._conn.cursor()
        cursor.execute(
            "INSERT INTO orders (id, book_id, quantity, total) VALUES (?, ?, ?, ?)",
            (order.id, order.book_id, order.quantity, order.total)
        )
        self._conn.commit()


# --- Service Layer: business logic only ---
import uuid


class OrderService:
    """Contains business rules for placing orders."""

    BULK_DISCOUNT_THRESHOLD = 5
    BULK_DISCOUNT_RATE = 0.10

    def __init__(self, book_repo: BookRepository, order_repo: OrderRepository):
        self._book_repo = book_repo
        self._order_repo = order_repo

    def place_order(self, book_id: str, quantity: int) -> Order:
        book = self._book_repo.find_by_id(book_id)
        if book is None:
            raise ValueError(f"Book '{book_id}' not found")

        if quantity > book.stock:
            raise ValueError(
                f"Requested {quantity} but only {book.stock} in stock"
            )

        # Business rule: bulk discount
        total = book.price * quantity
        if quantity >= self.BULK_DISCOUNT_THRESHOLD:
            total *= (1 - self.BULK_DISCOUNT_RATE)

        order = Order(
            id=str(uuid.uuid4()),
            book_id=book_id,
            quantity=quantity,
            total=round(total, 2)
        )

        self._order_repo.save(order)
        self._book_repo.update_stock(book_id, book.stock - quantity)

        return order


# --- Presentation Layer: HTTP handling only ---
class OrderController:
    """Handles HTTP requests — delegates to service layer."""

    def __init__(self, order_service: OrderService):
        self._order_service = order_service

    def create_order(self, request_body: dict) -> tuple:
        """Returns (status_code, response_body)."""
        try:
            order = self._order_service.place_order(
                book_id=request_body["book_id"],
                quantity=request_body["quantity"]
            )
            return 201, {
                "order_id": order.id,
                "total": order.total
            }
        except ValueError as e:
            return 400, {"error": str(e)}
        except KeyError:
            return 400, {"error": "Missing book_id or quantity"}
// --- Repository Layer ---

public class Book {
    private final String id;
    private final String title;
    private final double price;
    private int stock;

    public Book(String id, String title, double price, int stock) {
        this.id = id;
        this.title = title;
        this.price = price;
        this.stock = stock;
    }

    public String getId() { return id; }
    public String getTitle() { return title; }
    public double getPrice() { return price; }
    public int getStock() { return stock; }
    public void setStock(int stock) { this.stock = stock; }
}

public class Order {
    private final String id;
    private final String bookId;
    private final int quantity;
    private final double total;

    public Order(String id, String bookId, int quantity, double total) {
        this.id = id;
        this.bookId = bookId;
        this.quantity = quantity;
        this.total = total;
    }

    public String getId() { return id; }
    public String getBookId() { return bookId; }
    public int getQuantity() { return quantity; }
    public double getTotal() { return total; }
}

public class BookRepository {
    private final Connection connection;

    public BookRepository(Connection connection) {
        this.connection = connection;
    }

    public Book findById(String bookId) {
        // SQL query to find book — returns null if not found
        PreparedStatement stmt = connection.prepareStatement(
            "SELECT id, title, price, stock FROM books WHERE id = ?"
        );
        stmt.setString(1, bookId);
        ResultSet rs = stmt.executeQuery();
        if (!rs.next()) return null;
        return new Book(rs.getString("id"), rs.getString("title"),
                        rs.getDouble("price"), rs.getInt("stock"));
    }

    public void updateStock(String bookId, int newStock) {
        PreparedStatement stmt = connection.prepareStatement(
            "UPDATE books SET stock = ? WHERE id = ?"
        );
        stmt.setInt(1, newStock);
        stmt.setString(2, bookId);
        stmt.executeUpdate();
    }
}

public class OrderRepository {
    private final Connection connection;

    public OrderRepository(Connection connection) {
        this.connection = connection;
    }

    public void save(Order order) {
        PreparedStatement stmt = connection.prepareStatement(
            "INSERT INTO orders (id, book_id, quantity, total) VALUES (?, ?, ?, ?)"
        );
        stmt.setString(1, order.getId());
        stmt.setString(2, order.getBookId());
        stmt.setInt(3, order.getQuantity());
        stmt.setDouble(4, order.getTotal());
        stmt.executeUpdate();
    }
}

// --- Service Layer ---

import java.util.UUID;

public class OrderService {
    private static final int BULK_DISCOUNT_THRESHOLD = 5;
    private static final double BULK_DISCOUNT_RATE = 0.10;

    private final BookRepository bookRepo;
    private final OrderRepository orderRepo;

    public OrderService(BookRepository bookRepo, OrderRepository orderRepo) {
        this.bookRepo = bookRepo;
        this.orderRepo = orderRepo;
    }

    public Order placeOrder(String bookId, int quantity) {
        Book book = bookRepo.findById(bookId);
        if (book == null) {
            throw new IllegalArgumentException("Book '" + bookId + "' not found");
        }
        if (quantity > book.getStock()) {
            throw new IllegalArgumentException(
                "Requested " + quantity + " but only " + book.getStock() + " in stock"
            );
        }

        double total = book.getPrice() * quantity;
        if (quantity >= BULK_DISCOUNT_THRESHOLD) {
            total *= (1 - BULK_DISCOUNT_RATE);
        }

        Order order = new Order(
            UUID.randomUUID().toString(), bookId, quantity,
            Math.round(total * 100.0) / 100.0
        );

        orderRepo.save(order);
        bookRepo.updateStock(bookId, book.getStock() - quantity);

        return order;
    }
}

// --- Presentation Layer ---

public class OrderController {
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    public ResponseEntity createOrder(Map<String, Object> requestBody) {
        try {
            String bookId = (String) requestBody.get("book_id");
            int quantity = (int) requestBody.get("quantity");
            Order order = orderService.placeOrder(bookId, quantity);
            return new ResponseEntity(201, Map.of(
                "order_id", order.getId(),
                "total", order.getTotal()
            ));
        } catch (IllegalArgumentException e) {
            return new ResponseEntity(400, Map.of("error", e.getMessage()));
        }
    }
}

Notice how OrderService has no idea it's being called from an HTTP endpoint. You could call place_order() from a CLI script, a scheduled job, or a test — the business logic stays the same. And BookRepository has no idea about bulk discounts — it only knows how to read and write book data.

This separation is what architecture gives you.

Common Mistakes

Mistake 1: Choosing Architecture Before Understanding Requirements

The problem: A solo developer building a weekend project adopts Clean Architecture with six layers, 47 interfaces, and a dependency injection container — for a TODO list.

The fix: Start with the simplest architecture that handles your current complexity. A well-organized monolith beats a badly implemented clean architecture every time. You can always extract layers later.

Mistake 2: Layers That Don't Actually Separate Concerns

The problem: You have three layers, but the Service layer directly access HttpRequest objects, and the Repository layer contains pricing logic. The layers exist in name only.

The fix: Apply the litmus test: "Can I call this Service method from a CLI script without importing any HTTP library?" If not, HTTP concerns have leaked into the service layer. Similarly: "Does this Repository method contain any business rules?" If so, they belong in the Service layer.

Mistake 3: Skipping Architecture and Planning to Refactor Later

The problem: "We'll clean it up after launch." Except technical debt compounds. After launch, there are features to add, bugs to fix, and customers to support. The refactoring never happens.

The fix: Spend 30 minutes deciding on layer boundaries before writing code. You don't need perfect architecture — you need any clear separation of concerns. Even a rough layered structure is infinitely better than a monolithic tangle.

When to Use Which Architecture

Start with Monolith when:

  • You're a small team (1-5 developers)
  • The domain is straightforward (CRUD with light business logic)
  • Speed of initial delivery matters more than long-term flexibility
  • You're prototyping or validating a product idea

Move to Layered Architecture when:

  • Multiple developers work on different features simultaneously
  • You need testability — mocking database calls, testing business logic in isolation
  • The business logic is non-trivial (validation rules, pricing calculations, workflows)
  • You want to swap infrastructure (change ORM, switch database) without rewriting business logic
  • This is the default for LLD interviews — interviewers expect layered thinking

Consider Clean Architecture when:

  • The domain is genuinely complex (insurance, finance, healthcare)
  • The system will live for 5+ years with evolving requirements
  • Multiple delivery mechanisms exist (REST API, GraphQL, CLI, message consumers)
  • You need the domain to be completely independent of frameworks and databases

The practical progression:

Most successful projects follow this path: start with a well-structured monolith → extract into clear layers as complexity grows → adopt Clean Architecture principles if the domain demands it. Going straight to Clean Architecture for a simple app is over-engineering. Staying monolithic for a complex domain is under-engineering.

Key Takeaways

  • Software architecture is how you organize code into components and define their relationships — it's the blueprint that determines whether your codebase scales with your team and requirements
  • Monolithic architecture bundles everything into one deployable unit — fast to start, but becomes a coordination nightmare as the team grows
  • Layered architecture separates Presentation, Service, and Repository concerns into horizontal layers with downward-only dependencies — this is the default for most team projects and LLD interviews
  • Clean Architecture places the business domain at the center with the Dependency Rule (everything depends inward) — ideal for complex domains but adds abstraction overhead
  • Architecture decisions are expensive to reverse — spend time upfront choosing the right level of structure for your project's complexity
  • The SOLID principles you learned in S6 apply at the architecture level too: each layer follows Single Responsibility, layers communicate through abstractions (Dependency Inversion), and new features are added without modifying existing layers (Open/Closed)
  • In the next tutorial, we'll zoom into Layered Architecture and build a complete three-layer bookstore system with clear boundaries between Presentation, Service, and Repository