Lesson 1 - Software Design and Architecture

Welcome to Software Design and Architecture

Ledgerly started as one small Python program that a three-person team could read from top to bottom in an afternoon. Module 1 covered the principles that kept that program clean inside — modularity, abstraction, encapsulation, DRY, KISS, and YAGNI, plus the five SOLID principles for structuring classes. This lesson opens Module 2, Design, Architecture & OOP, and steps back to a bigger question: not how one class should be written, but how the whole system should be shaped as Ledgerly grows from a handful of files into something with real user traffic.

Architecture decisions outlive most of the code they govern. A single class is easy to rewrite in an afternoon. Undoing a bad architecture decision, like tangling every layer of the system together, can take months. This lesson gives you three tools for thinking about system shape: layered architecture, the MVC pattern, and the monolith-versus-microservices trade-off, all grounded in choices Ledgerly’s team actually has to make.

By the end of this lesson, you will be able to:

  • Explain the difference between software architecture and low-level design
  • Organize a system into presentation, business logic, and data access layers
  • Apply the MVC pattern to separate data, display, and control flow
  • Compare a monolith with a microservices split and name the real trade-offs
  • Decide when splitting out a service, like Ledgerly’s billing logic, is worth the cost

Architecture vs. Low-Level Design

Software architecture is the set of decisions about how major parts of a system are organized and how they talk to each other. Low-level design is the set of decisions about how one part is built inside. Architecture answers questions like “should billing run inside the main app or as its own service?” Design answers questions like “should InvoiceService take a repository object or a raw database connection?”

Think of architecture as deciding how many buildings a small town needs and where the roads between them go. Design is deciding how one building’s rooms connect once its location and size are already fixed. Module 1’s principles, like loose coupling and single responsibility, apply at both levels, but architecture applies them to entire services and databases, while design applies them to individual classes and functions.

For Ledgerly, an architecture decision is choosing whether the invoicing logic and the billing logic live in one process or two. A design decision, made after that choice, is how InvoiceService structures its methods so it stays easy to test. Getting the architecture right first matters, because a design mistake inside one class is a quick fix, while an architecture mistake can force a rewrite of how every service talks to every other service.


Layered Architecture: Presentation, Business Logic, Data Access

Layered architecture organizes a system into horizontal layers, where each layer only talks to the layer directly below it. A typical split has three layers: presentation (handles requests and responses), business logic (applies rules), and data access (reads and writes storage). Each layer hides its details from the layers above it.

Here is what Ledgerly’s invoice creation looked like before any layers existed. One class receives the request, checks the amount, and writes straight to storage.

class InvoiceEndpoint:
    """Handles the request, applies the rule, and touches storage, all in one place."""

    def __init__(self):
        self.invoices_db = {}

    def handle_create_invoice(self, customer_id, amount_cents):
        if amount_cents <= 0:
            return {"error": "Invalid amount"}
        invoice_id = f"INV-{len(self.invoices_db) + 1:04d}"
        self.invoices_db[invoice_id] = {
            "customer_id": customer_id,
            "amount_cents": amount_cents,
            "status": "open",
        }
        return {"invoice_id": invoice_id, "status": "open"}


endpoint = InvoiceEndpoint()
print(endpoint.handle_create_invoice(101, 4200))
{'invoice_id': 'INV-0001', 'status': 'open'}

This works for a small app, but every change risks touching everything else. Swap the storage from a dictionary to a real database, and the validation rule sits in the same method you have to edit. Add a second way to create invoices, like an import job, and you copy the validation rule again.

The layered version separates the same work into three classes, each with one job.

class InvoiceRepository:
    """Data access layer. Only this class knows how invoices are stored."""

    def __init__(self):
        self._invoices = {}

    def save(self, invoice_id, record):
        self._invoices[invoice_id] = record

    def count(self):
        return len(self._invoices)


class InvoiceService:
    """Business logic layer. Applies the rules, knows nothing about HTTP or storage details."""

    def __init__(self, repository):
        self.repository = repository

    def create_invoice(self, customer_id, amount_cents):
        if amount_cents <= 0:
            raise ValueError("Invalid amount")
        invoice_id = f"INV-{self.repository.count() + 1:04d}"
        record = {"customer_id": customer_id, "amount_cents": amount_cents, "status": "open"}
        self.repository.save(invoice_id, record)
        return invoice_id, record


class InvoiceController:
    """Presentation layer. Translates a request into a service call and a response."""

    def __init__(self, service):
        self.service = service

    def handle_create_invoice(self, customer_id, amount_cents):
        try:
            invoice_id, record = self.service.create_invoice(customer_id, amount_cents)
            return {"invoice_id": invoice_id, "status": record["status"]}
        except ValueError as error:
            return {"error": str(error)}


controller = InvoiceController(InvoiceService(InvoiceRepository()))
print(controller.handle_create_invoice(101, 4200))
print(controller.handle_create_invoice(102, -50))
{'invoice_id': 'INV-0001', 'status': 'open'}
{'error': 'Invalid amount'}

InvoiceController never touches storage directly. InvoiceService never formats a response. InvoiceRepository never checks whether an amount is valid. If Ledgerly switches from an in-memory dictionary to PostgreSQL, only InvoiceRepository changes, and InvoiceService and InvoiceController stay exactly as they are.

Layers are a design pattern, not an architecture on their own

Layered architecture describes how code is organized inside one running process. It does not by itself decide whether that process is a single monolith or one of several microservices. Ledgerly can (and does, in this lesson) use layers inside a monolith, and each microservice it might later split out would use the same layered pattern inside itself.


The MVC Pattern

MVC (Model-View-Controller) is a specific way of applying layered thinking to anything with a user-facing display. The model holds data and the rules for changing it. The view turns a model into something a person can read. The controller receives input, tells the model what to do, and picks a view to show the result.

class Invoice:
    """Model: owns invoice data and the one rule for computing a total."""

    def __init__(self, invoice_id, line_items):
        self.invoice_id = invoice_id
        self.line_items = line_items

    def total_cents(self):
        return sum(item["price_cents"] * item["quantity"] for item in self.line_items)


class InvoiceView:
    """View: turns a model into a display string. Knows nothing about requests."""

    @staticmethod
    def render_receipt(invoice):
        total = invoice.total_cents() / 100
        return f"Receipt {invoice.invoice_id}: ${total:.2f} due"


class InvoiceController:
    """Controller: receives input, updates the model, and picks a view to render it."""

    def __init__(self):
        self.invoices = {}

    def create_invoice(self, invoice_id, line_items):
        invoice = Invoice(invoice_id, line_items)
        self.invoices[invoice_id] = invoice
        return InvoiceView.render_receipt(invoice)


controller = InvoiceController()
receipt = controller.create_invoice("INV-2001", [{"price_cents": 1500, "quantity": 2}])
print(receipt)
Receipt INV-2001: $30.00 due

Invoice (the model) has no idea a receipt string will ever be built from it. InvoiceView has no idea where an Invoice came from. InvoiceController is the only piece that knows both exist and connects them. If Ledgerly later needs a second display, like a PDF receipt instead of a plain string, only a new view class is needed. Neither Invoice nor InvoiceController has to change.

MVC and the three-layer split from the previous section overlap on purpose. The model plays a role close to business logic plus data, the view plays a role close to presentation, and the controller coordinates between them. The names differ across frameworks, but the underlying goal is the same: keep data, display, and control flow from becoming one tangled piece of code.


Monolith vs. Microservices

A monolith is a system where all the code runs as one deployable unit, in one process, usually against one database. Microservices split a system into several independently deployable units that communicate over a network, each often owning its own database. Neither option is universally better. The right choice depends on team size, traffic patterns, and how independently different parts of the system need to change.

Ledgerly today is a monolith: InvoiceController, InvoiceService, and a BillingService all run in the same process, called through ordinary function calls. As Ledgerly grows, billing traffic (charging cards, retrying failed payments, handling subscription renewals) could grow faster than invoice-viewing traffic. That growth is one real reason a team might split billing into its own service.

class InProcessBillingService:
    """Monolith style: billing logic runs in the same process as the rest of Ledgerly."""

    def charge_invoice(self, invoice_id, amount_cents):
        return {"invoice_id": invoice_id, "charged_cents": amount_cents, "via": "in-process"}


class RemoteBillingClient:
    """Microservice style: stands in for a network call to a separate billing service."""

    def __init__(self, service_host):
        self.service_host = service_host

    def charge_invoice(self, invoice_id, amount_cents):
        # A real version sends an HTTP request to self.service_host.
        # No network call happens here; this simulates the response shape.
        return {
            "invoice_id": invoice_id,
            "charged_cents": amount_cents,
            "via": f"http:{self.service_host}",
        }


def process_payment(billing_client, invoice_id, amount_cents):
    return billing_client.charge_invoice(invoice_id, amount_cents)


monolith_result = process_payment(InProcessBillingService(), "INV-3001", 9900)
microservice_result = process_payment(RemoteBillingClient("billing.ledgerly.internal"), "INV-3001", 9900)
print(monolith_result)
print(microservice_result)
{'invoice_id': 'INV-3001', 'charged_cents': 9900, 'via': 'in-process'}
{'invoice_id': 'INV-3001', 'charged_cents': 9900, 'via': 'http:billing.ledgerly.internal'}

process_payment does not know or care which kind of billing client it received, which is the same abstraction lesson from Module 1 applied at the architecture level. The difference that matters is everything hidden behind charge_invoice. The in-process version is one function call: fast, and it fails only if the code itself has a bug. The remote version crosses a network: it can be scaled and deployed on its own, but it can also fail because of a timeout, a dropped connection, or the other service being down, none of which the in-process version has to worry about.

Monolith strengths: one codebase to run and debug, no network calls between your own components, simple to deploy, cheap to operate with a small team.

Monolith weaknesses: every part scales together, so a billing traffic spike also takes CPU away from invoice viewing; one bug can affect the whole app; a large team working in one codebase can start stepping on each other’s changes.

Microservices strengths: each service scales on its own; a bug in billing does not crash invoice viewing; different teams can own and deploy different services independently.

Microservices weaknesses: network calls between services are slower and can fail in ways function calls cannot; keeping data consistent across two databases is harder; a three-person team now has two (or more) systems to monitor, deploy, and keep running.


Making the Call for a Growing App

Choosing an architecture is a trade-off, not a search for one correct answer. A useful test is to ask what problem the current architecture is actually causing, rather than what problem a bigger company’s architecture solves for them. Instagram and Twitter both ran as monoliths long after they had real users, because splitting services too early adds coordination cost with no matching benefit.

For Ledgerly, staying a monolith today makes sense: the team has three people, traffic is modest, and every part of the system still changes together often enough that one deployable unit is the cheaper option. The billing split shown above becomes worth the network overhead only once specific signals appear: billing traffic clearly outpaces the rest of the app, a payment provider outage would ideally not also take down invoice viewing, or a dedicated team forms to own billing on its own schedule. Until one of those is true, extracting a service adds operational cost — new deployments, new monitoring, a network call that can time out — without a matching benefit.

Diagram titled 'Ledgerly: from a layered monolith to an extracted billing service' with two panels. Left panel, 'Today: layered monolith', shows a single stack: a Presentation layer box (InvoiceController), an arrow down to a Business logic layer box (InvoiceService, PricingEngine), an arrow down to a Data access layer box (InvoiceRepository, BillingRepository), and an arrow down to one shared database. A note below reads: one process, one deploy, one team can read it all, but billing spikes slow down invoice lookups too. Right panel, 'Tomorrow: billing extracted', shows two stacked boxes connected by an arrow labeled 'calls over HTTP, not a function call': the top box, Ledgerly main app, contains Presentation plus business logic (InvoiceController, InvoiceService) and its own InvoiceRepository with its own database; the bottom box, Billing service, contains PaymentGateway and RemoteBillingClient logic plus its own BillingRepository with a separate database. A note below reads: two processes, two deploys, billing scales on its own, but a network hop can fail where a function call never could. A caption strip at the bottom reads: same layers inside each box, the architecture decision is where the boundary between boxes goes.
Ledgerly keeps the same three layers — presentation, business logic, data access — whether it runs as one process today or splits billing into its own service tomorrow. The architecture decision is where the boundary between processes goes, not whether layers exist.

The boundary matters more than the buzzword

“Microservices” is not an upgrade you install. It is a boundary you draw between processes, and every boundary you draw has to be crossed by a network call instead of a function call. Draw it where a real, current problem justifies the cost, like billing traffic that is already outgrowing the rest of the app, not where it sounds more advanced.


Practice Exercises

Exercise 1: Add a reporting layer without breaking the others

Ledgerly wants a monthly revenue report that reads invoice totals but never creates or changes an invoice. Using the layered classes from this lesson (InvoiceRepository, InvoiceService, InvoiceController), describe where a new RevenueReportService should sit, and which existing class it should depend on.

Hint

RevenueReportService belongs in the business logic layer, alongside InvoiceService. It should depend on InvoiceRepository directly (to read stored invoices), not on InvoiceService, since it is not creating invoices and does not need those rules. It should expose its own method, like total_revenue_for_month(), and InvoiceController (or a new ReportController) calls it the same way InvoiceController calls InvoiceService.

Exercise 2: Identify the pattern

A teammate builds a Customer class that stores name and email, a CustomerFormView class that turns a Customer into an HTML form, and a CustomerFormHandler class that reads submitted form data, updates a Customer, and picks which view to show next. Name the pattern being used, and match each class to its role.

Hint

This is MVC. Customer is the model (owns data and rules), CustomerFormView is the view (turns the model into something displayable), and CustomerFormHandler is the controller (receives input, updates the model, chooses a view). The naming differs slightly from Invoice/InvoiceView/InvoiceController in this lesson, but the three roles are identical.

Exercise 3: Decide, don’t guess

Ledgerly’s three-person team is debating whether to split NotificationService (currently sending email reminders in-process) into its own microservice, because “microservices are what scaling companies use.” Using the decision criteria from this lesson, what would you tell them?

Hint

Wanting to look like a scaling company is not one of the real signals from this lesson. Ask instead: is notification traffic actually outgrowing the rest of the app, would a notification outage ideally not take down invoicing too, and is there a dedicated team to own it. With three people and no evidence of any of those problems, keeping NotificationService inside the monolith is the cheaper and simpler choice for now.


Summary

Architecture decides how the major parts of a system are organized and how they communicate; design decides how one part is built inside. Layered architecture splits a system into presentation, business logic, and data access, so a change to one, like swapping InvoiceRepository’s storage, does not ripple into the others. MVC applies the same idea to anything with a display, separating a model’s data and rules from the view that renders it and the controller that connects them. A monolith keeps every part in one process for simplicity; microservices split parts into independent, networked pieces for independent scaling and deployment, at the cost of network failures and operational overhead neither Ledgerly nor most small teams should take on before a real signal justifies it.

Key Concepts

  • Software architecture — decisions about how major system parts are organized and communicate, distinct from the low-level design of one part.
  • Layered architecture — organizing a system into presentation, business logic, and data access layers, each depending only on the layer below it.
  • MVC (Model-View-Controller) — a pattern where the model owns data and rules, the view renders it, and the controller connects input to both.
  • Monolith — a system deployed and run as one process, usually against one database.
  • Microservices — a system split into independently deployable services that communicate over a network, often with separate databases.
  • Architecture trade-off — every boundary between services buys independent scaling and deployment at the cost of network failures and added operational work, and should be drawn only where a real problem justifies it.

Why This Matters

Every system Ledgerly’s team builds from here forward sits on top of the decisions in this lesson. Layered architecture and MVC keep individual services readable as they grow, the same way Module 1’s principles kept individual classes readable. The monolith-versus-microservices trade-off protects the team from two opposite mistakes: staying tangled together past the point where it hurts, and splitting apart before any real problem demands it. Object-oriented design, covered starting in the next lesson, is what makes each layer and each service actually buildable once the architecture around them is decided.


Next Steps

Lesson 2: Object-Oriented Programming in Practice

Apply classes, inheritance, and composition to build Ledgerly's layers with real object-oriented code.

Back to Module Overview

Return to the Design, Architecture & OOP module overview


Continue Building Your Skills

You now have three tools for reasoning about system shape: the architecture-versus-design distinction, layered architecture with MVC as a common specialization, and the monolith-versus-microservices decision. The next lesson moves from the shape of the whole system down to the object-oriented building blocks, classes, inheritance, and composition, that make each layer of Ledgerly’s architecture real.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com