Skip to content

Architecture

Adrian Rocke edited this page Feb 22, 2025 · 1 revision

Principles

  • Cohesion: We want to organize our code around areas of high cohesion. Code that changes together should be near each other.
  • Loose coupling: Areas of code that have low cohesion should be loosely coupled. This requires narrowly defined boundaries between these areas.
  • Necessary complexity: We want to be mindful where we are introducing complexity. Prioritize solving complexity in the data model, not technical complexity due to software design.
  • Testability: Code should be written in a way that can be tested, as this increased confidence in our deployments

Modules

Modules are the primary way we organize our system.

  • High cohesion: Modules should have high cohesion around a specific area of the platform (users, translation, notifications, etc.). We want to try to store as much code as we can in a module. This can include Next.js server actions and components.
  • Medium coupling: We tolerate some coupling between modules to reduce complexity. This is a short term tradeoff we are making for development speed.
    • Mutation ownership: Modules are responsible for all changes to the tables they own. Modules should not write to tables in other modules.
    • Query overlaps: Modules can query other tables to power front-end views. This eliminates the need for data duplication in order to preserve stricter boundaries and looser coupling. Note that if a model needs information from another module to make decisions, it should use the model rather than a custom database query.

Organization

flowchart TD
  subgraph Presentation
  server-action[Next.js Server Action]
  api[Next.js API route]
  component[Next.js Component]
  end

  subgraph Application
  use-case[Use Case]
  end

  subgraph Model
  domain[Domain Object]
  dao[Data Access Object]
  read[Read Model]
  end

  subgraph Data Access
  repo[Repository]
  query[Query Service]
  db[Database]
  repo --> db
  query --> db
  end

  server-action --> use-case
  component --> use-case & read
  api --> use-case & read
  worker --> use-case
  use-case --> domain & dao
  domain & dao --> repo
  read --> query
Loading

You might be wondering why we have all of these layers. Here are couple benefits to this system, and what tradeoffs we are tolerating:

  • Isolation: Persistence and presentation are isolated from the rest of the system, which makes them easier to change and improve independent of the logic of the system.
  • Testability: Complex domain models are isolated from persistence and presentation concerns which makes them easy and fast to unit tests. Similarly, dependencies to use cases can be easily mocked, so they can also be unit tested.
  • Discoverability: A domain model is much easier to read than something strung out over several views and server actions. Use cases are a quick way to find what commands are defined in the system. Imagine hunting through the Next.js app directory for different places where data was loaded and changed.

Tradeoffs

  • Verbosity: Defining models, repositories, and use cases is more verbose than a direct SQL query in a server action. We tolerate this because we don't want to have to hunt through each server action to figure out where data is changing.
  • Slow startup: It takes more time to scaffold out a new model with its repository. We should earn back this time as the system grows and we need to understand and test the model and use cases.

Folder Structure

/src/modules/[module-name]/
  actions/
  route-handlers/
  models/
  data-access/
  actions/
  use-cases/

Use Cases

Every module will have a set of use cases that describe all of the things it can do.

  • File naming: Use cases should be in separate files named using TitleCase.
  • Purpose: Use cases should be thin. Their purpose is to glue together a request with the model and data access layer.
  • Design: Use cases should expose a function execute that takes a request object. Dependencies should be injected so they can be mocked in unit tests.
  • Unit tests: Use cases should be unit tested to confirm everything is wired up correctly. We are interested in testing how the use case translates requests into domain model actions, and handles responses and errors. You don't need to duplicate all of the tests from the model.

Models

Models manage the data in the system and provide a common interface to make changes. We use two kinds of models depending the complexity of the logic within the model: Domain Objects and Data Access Objects.

Data Access Objects

  • Construction: Data access objects are types that describe plain Javascript objects.
  • Purpose: Data access objects are designed for simple models with little complexity, usually limited to the CRUD operations.
  • Data access: These models are loaded and persisted by the data access layer which may have several methods to create, update, and delete.

Domain Objects

  • Construction: Domain objects are constructed from classes that represent Value Objects, Entities, and Aggregate Roots.
  • Purpose: Domain objects encapsulate complex models in isolation from the rest of the system. This allows them to be tested independently of how they are persisted or shown to the user.
  • Readonly properties: The model should describe all of it's data as a private props property on the class, and expose it's data through readonly getters.
  • Mutate using methods: Updates to the model should happen through methods, where validation can take place. This is preferable to setter methods where a function call and possible exception are hidden from the programmer.
  • Unit test: Update methods on the model should be unit tested.

How to pick a model type

  • Simple models: When the data in a model follows simple CRUD patterns, use a Data Access Object.
  • Complex models: When the data in a model follows complex business rules, use a Domain Object.
  • Combinations: Some modules may have a mix of both kinds of models. It's ok to use a Data Access Object for simple supporting data for a more complex Domain Object.
  • Evolving: When you find yourself putting domain logic into the Data Access Layer for Data Access Objects, that's a sign that the model may need to shift to a Domain Object

Data Access

Query Services

We use Query Services to implement all of the queries that drive views.

  • Simple objects: Query services have no state, so they should be defined as an interface and a simple object that implements it, rather than a class.
  • Query options: Extend methods with options that influence the query rather than many similar methods. That creates less duplication.
  • No mutations: Query services should only have methods that return data. Mutations require the use of a repository and a model.
  • Data boundaries: Query services can query across module boundaries. This eliminates the need for data duplication between modules. As we grow, we may introduce context boundaries between groups of modules.

Repositories

We use the Repository Pattern to load and persist our models.

  • Simple objects: Repositories have no state, so they should be defined as an interface and a simple object that implements it, rather than a class.
  • Loading models: Use find* methods to load domain objects and data access objects from the database
  • Actions on data access object: Use create, update, and delete methods to modify data access object.s
  • Committing domain objects: Use the commit method to save a domain object to the database. The model should handle the lifecycle to create, update, and delete domain objects.
  • Hide database model: Database data should only leave and enter the repository through a domain model. The database model stays within the repository.

Server Actions

Server actions are the primary way the site handles updates from users.

  • Purpose: Server actions are a presentation concern. Actions connect use cases, which are agnostic to the presentation framework, to the Next.js ecosystem.
  • Logging: Server actions should use serverActionLogger with the name of the function. This logs the action to the log stream, and associates all logs within the action together for easier debugging.
  • Request validation: Server actions should use a zod schema with parseForm to validate the request body. If coming from a form, the error response should have validation information to populate the form errors.
  • Authorization: Server actions should use verifySession and verifyAction to confirm the user has permissions to perform the action before proceeding.
  • Delegate logic: All logic should be run through a use case. The server action itself is just the glue that connects the Next.js client to the use cases
  • Errors: Use handleError to create an error response for common errors. Unique errors that need to be returned to the user (as opposed to treating them as unknown errors) need to be handled in the action.

Route Handlers

  • Purpose: Route handlers are a presentation concern. We typically only use route handlers when lazy loading data. Use server actions for mutating data from the frontend.

Clone this wiki locally