DEV Community

Victor N.
Victor N.

Posted on • Originally published at fullsteak.dev

Full-stack ReScript. Architecture Overview

So, ReScript. I guess you have heard about this beautiful and pragmatic language in the context of reliable and enjoyable web development. If you dive into learning resources around ReScript, you’ll see most of them focus on React/UI/front-end development, leaving the server-side with no attention. But can ReScript also be used to create a full-featured back-end? In this article, I’d try to prove it can and does it with success.

I have two projects running in production, which are primarily done in ReScript (with some routine JS glue) from the front to the back:

  • Future Corporations — a multiplayer game for web and mobile platforms
  • Figuro — an on-demand laser cut service where a user uploads his drawing, chooses material, and voilà gets his widget delivered the next day

They both are small-scale, but I’m sure I found some architecture and patterns that would allow me to scale them quickly and easily if it is required. ReScript is one of the few options to do an entire project using the only platform in the functional programming paradigm. A project can be quickly done solo or by a small team, avoiding cognitive context switches, code duplication, etc.

Let me unwind both project internals to show how they work on the high level leaving specific implementation details out of the scope.

Disclaimer: There’re infinite ways to shape your project. I’m describing just one possible way. I did not invent this architecture, but I gathered ideas from DDD (Domain Driven Development) and satellite paradigms, then projected them to ReScript. The suggested architecture might not fit your needs, but it works well for the projects I’m talking about.

To be more specific, the article is not about ReScript. It’s about architecture. The same principles can be easily used with any FP language like F# or PureScript or even in a non-FP platform (JavaScript, TypeScript) if you follow functional programming principles.

One message journey

Even though projects use different transports, storage services, and deployment mechanisms, they share the same architecture. The architecture is made up of layers that are strictly isolated and play different roles on the whole.

Here’s how a typical user action and its corresponding reaction look like:

What if a player pushed a “pew-pew” button

😲 Quite many arrows and boxes for a single “Pew-pew,” right? Don’t be afraid; this is simplified; there’re more actually. However, every layer here is for a reason, and let’s take a look at them one by one from left to right. Depending on your project, you can omit some layers.

Let’s walk through the most complex scenario.

Front-end

Depicted as a single “React SPA” layer here, it contains more layers inside in practice. But the focus of this article is the server-side, so it is simplified. What’s important is that this layer often initiates requests on behalf of a user and renders responses to present results.

Such requests are usually sent inside React.useEffect or triggered by UI event handlers such as onClick. The requests might be delivered via various transports. For example, in the Figuro web project, they are simple asynchronous HTTP requests. Whereas in the Future Corporations game, they are sent over websocket connection because a persistent connection better serves a real-time game with two-way communication.

JSON vs DTO

A request usually contains some useful payload. This payload is sent over the wire, so it should be a basic structure serializable to a string or byte buffer. You know, JSON is a popular format to exchange messages, and I use it as well. However, it might be BSON, XML, protobuf, or a home-grown format.

In a strongly-typed language such as ReScript (or F#, or PureScript), it is pretty impractical to deal with raw JSON (the Js.Json.t type), so the first thing to do on a boundary is to convert it to a so-called DTO: data transfer object. DTOs are simple nested strongly-typed records that are trivial to convert to JSON and back. You’ll get no DTOs in JavaScript because JSONs are DTOs already there, albeit without any checking 🤞

In ReScript, you’d have another layer (not shown on the diagram for simplicity) to convert JSON to DTO and back. To illustrate the difference, here’s JSON:

{"buy-in":900,"tax":100}
Enter fullscreen mode Exit fullscreen mode

and here’s DTO type:

type fee = {
  buyIn: float,
  tax: float,
}
Enter fullscreen mode Exit fullscreen mode

The difference might seen minor (it is), but:

  • (a) your can work with DTO fields semantically whereas it’s hardly possible with black-boxed Js.Json.t (data->Js.Json.decodeObject->Option.getExn->Js.Dict.get("tax")->Option.getExn->Js.Json.decodeNumber->Option.getExn anyone?),
  • (b) you can perform trivial invariant validations at the stage of conversion to check whether all fields are present or timestamps are specified in a proper ISO format.

Despite all fuzz, DTOs are intermediate technical objects that are not interesting to business logic. The business logic wants to work with Money.t (and not float) that places much more restrictions on the possible values, has several ways to present the data, etc. The conversion between a DTO and model is the responsibility of further layers. I mention it here to say that checking business rules is not a part of the JSON-to-DTO layer.

Gateway layer

Gateway is a process that listens on a public hostname and ports well-known to the front end. Its role is to route incoming requests further to a proper Server App and then passing responses back, possibly modifying message metadata. A gateway can be a ready-made solution such as NGINX reverse proxy, Kubernetes Ingress, or Traefik. Or it can be a small custom daemon if existing solutions do not fit. Your project will usually have zero or one gateway.

In the case of the Figuro laser-cut web app, I have no gateway at all because it is a NextJS monolith at the moment, and all requests are sent directly to the Server App. But the Future Corporations game has a custom thin gateway that keeps websocket connections with clients on one side and NATS message queue on the other side. The gateway is some proxy that converts the websocket protocol suitable for clients’ browsers and mobile apps to NATS messages used to talk between server apps. It also does the reverse: looks for some NATS messages interesting to clients and, once found, forwards them to relevant recipients over websocket.

Gateway

Server app layer

A server app, also called “service app” or “daemon,” is a thing that defines process boundaries. That is, a server app is a deployment unit. You run this thing as a Docker container, or with pm2, or straight as systemd service, whatever deployment strategy you choose.

You’ll have one server app if you build a monolith or several (dozens, hundreds) if you build mini-/microservices. For example, the Figuro web app has a single app: the NextJS app itself. And the Future Corporations game has seven miniservices at the moment. Each server app is responsible for its own activity: checking in players for a new online game (searching for opponents), customizing a player profile, holding and advancing running game state, triggering timeouts if a player has gone sleep, tracking statistics for further analytics, and so on.

The activities themselves are served mainly by other layers: handlers, domain services, and domain models (described below). The primary purpose of the server app itself is to run all these layers and call them when necessary.

Server app

You can see the “App Module” at the chain start. This is the only code that actually belongs to the server app layer. Its mission is:

  • Read config (from environment variables, for example)
  • Connect to network services, start listening on given ports
  • Subscribe to MQ topics of interest or URL paths in case of HTTP
  • Engage timers if the app acts as a cron-job rather than a network request handler
  • Take requests, deserialize their payload, and forward them to Handler
  • Serialize handling results and errors to send them back over HTTP or MQ
  • Write logs
  • Reply to health checks

The app layer is hardest to test because you’d have to issue actual HTTP requests or MQ messages and check responses the same way. Luckily, the App Module is easy to make very thin and almost boilerplate. I never cover this layer with tests.

Summarizing the above, the Server App layer is technically just a container for other layers so that an OS can run the app. All interesting happens further.

Handler layer

The handler layer knows what to do with requests. The hosting server app provides a handler with such requests in the form of inbound messages such as “player acted,” “player has gone offline,” “room is full,” “timeout expired,” etc. The job of a handler is to take such message, convert its DTO payload to domain objects, perform actions, interact with a database or other external services, and finally return a list of outbound messages such as “game updated,” “game finished” so that the caller can make responses. We know the caller is the server app, but the handler itself knows nothing about it.

The handler layer is the last one that is allowed to perform IO and make side effects. For example, interact with the database, file system, or e-mail sending SaaS. They do it via adapters that are configured and provided by the hosting app module. To be more precise, it is their primary duty. They act as clerks:

  • Decode incoming messages
  • Read external storage
  • Forward this further to the service layer, which actually makes business decisions
  • Get decisions back and record the results in the database
  • Encode domain objects back to DTO and return results to a caller

Here’s an example of check-in app handler interface:

module InboundMessage = CheckinMessages.InboundMessage
module OutboundMessage = CheckinMessages.OutboundMessage

type t

// Possible results
type handleResult =
  | Message(OutboundMessage.t)
  | ServerError(string)

// Room and Profile store are adaptors to interact with data in PostgreSQL database
let make: (RoomStore.t, ProfileStore.t) => t

// The essense of a handler
let handle: (t, InboundMessage.t) => Promise.t<array<handleResult>>
Enter fullscreen mode Exit fullscreen mode

And implementation outline:

module InboundMessage = CheckinMessages.InboundMessage
module OutboundMessage = CheckinMessages.OutboundMessage
module Service = CheckinService
module Command = Service.Command
module Event = Service.Event
module Player = AxEngine.Player
module Room = LobbyDomain.Room
module RoomTable = LobbyDomain.RoomTable
module RoomPlayer = LobbyDomain.RoomPlayer

type t = {
  roomStore: RoomStore.t,
  profileStore: ProfileStore.t,
}

type handleResult =
  | Message(OutboundMessage.t)
  | ServerError(string)

let make = (roomStore, profileStore) => {roomStore: roomStore, profileStore: profileStore}

type commandOrQuery =
  | Command(Command.t)
//| Query(query); // no queries in this miniservice, only commands

// An inbound message is either a command to do something, or a query to read
// some data. This function interprets an inbound message segregating commands
// and queries. The latter are applied directly to stores skipping further
// processing layers.
let toCommandsAndQueries =
  (
    handler,
    inMessage: InboundMessage.t
  ): Promise.t<result<array<commandOrQuery>, 'err>> =>
  switch inMessage {
  | PlayerPresenceUpdated({playerId, online: false}) =>
    /* ... */

  | PlayerPresenceUpdated({online: true}) =>
    /* ... */

  | PlayerGameStartSubscriptionUpdated({playerId, subscribed}) =>
    Command.UpdateWaitMode(PlayerId(playerId), subscribed ? OnlineAndOffline : OnlineOnly)
    ->Command
    ->Array.just
    ->Ok
    ->Promise.resolve

  | RoomJoin(playerId, plainRoomAddress) =>
    /* ... */

  | RoomLeave(playerId) =>
    /* ... */
  }

// No queries in this miniservice. But if there were a few, this function would
// query DB via store adapters.
//let handleQuery = (handler, query) => raise(Exn.NotImplementedYet)

// This function reformats domain-level events to publicly consumable
// outbound messages if required
let toOutboundMessages = (handler, events): Promise.t<array<OutboundMessage.t>> => {
  let {profileStore} = handler
  events->Array.map((event: Event.t) =>
    switch event {
    | RoomCreated(_) =>
      // It is OK to not announce some events
      []->Promise.resolve

    | PlayerJoinedRoom(playerId, room) =>
      OutboundMessage.PlayerJoinedRoom({
        playerId: playerId->PlayerId.toInt,
        roomAddress: room->Room.address->Room.Address.toString,
        buyin: room->Room.buyin,
        tax: room->Room.tax,
      })
      ->Array.just
      ->Promise.resolve

    | PlayerLeftRoom(playerId, room) =>
      OutboundMessage.PlayerLeftRoom({
        playerId: playerId->PlayerId.toInt,
        roomAddress: room->Room.address->Room.Address.toString,
        refund: room->Room.buyin,
      })
      ->Array.just
      ->Promise.resolve

    | RoomFilledUp(roomTable) =>
      /* ... */

    | WaitModeUpdated(_, _) =>
      /* ... */
    }
  )
  ->Promise.all
  ->Promise.map(Array.concatMany)
}

// React to events by sending update queries to our database
// through LalalaStore adapters. They effectivelly convert
// function calls to SQL expressions and execute them on DB
let applyEventsToStore = (handler, events) => {
  let {roomStore, profileStore} = handler
  events->Promise.runInSequence(event =>
    switch (event: Event.t) {
    | RoomCreated(room) => roomStore->RoomStore.create(room)
    | PlayerJoinedRoom(playerId, room) =>
      roomStore->RoomStore.addPlayerToRoom(playerId, room->Room.id)
    | PlayerLeftRoom(playerId, room) =>
      roomStore->RoomStore.removePlayerFromRoom(playerId, room->Room.id)
    | RoomFilledUp(roomTable) =>
      roomStore->RoomStore.markAsPlaying(roomTable->RoomTable.room->Room.id)
    | WaitModeUpdated(playerId, mode) =>
      profileStore->ProfileStore.updateWaitMode(playerId, mode)
    }
  )->Promise.erase
}

let handleCommand = (handler, command) =>
  switch (command, Service.execute(command)) {
  | (_, Ok(events)) =>
    // The happy path: we execute a command, get events (their descriptions),
    // save data to DB and convert them to outbound messages
    handler
    ->applyEventsToStore(events)
    ->Promise.then(() =>
      handler
      ->toOutboundMessages(events)
      ->Promise.map(Array.map(_, x => Message(x)))
    )

  | (JoinRoom(roomPlayer, joinTarget), Error(err)) =>
    // Custom error handling is possible when we express a domain error
    // as a valid scenario with its own message
    let roomAddress = switch joinTarget {
    | New(roomAddress) => roomAddress
    | Existing(roomTable) => roomTable->RoomTable.room->Room.address
    }
    let reason = switch err {
    | #RoomFull => #ALREADY_FULL
    | #BadNumberOfPlayers => #BAD_ADDRESS
    | #RoomDoesNotExist => #NOT_EXIST
    }
    Message(
      RoomJoinError({
        playerId: roomPlayer->RoomPlayer.playerId->PlayerId.toInt,
        roomAddress: roomAddress->Room.Address.toString,
        reason: reason,
      }),
    )
    ->Array.just
    ->Promise.resolve

  | (_, Error(err)) =>
    // Generic error. The caller should log it, send to Sentry,
    // call admin whatever
    ServerError(
      Js.Json.stringifyAny(err)
      ->Option.getWithDefault("<not serializable error>")
    )
    ->Array.just
    ->Promise.resolve
  }

let handle = (handler, inMessage) =>
  handler->toCommandsAndQueries(inMessage)->Promise.then(coqs =>
    switch coqs {
    | Ok(coqs) =>
      coqs->Promise.runInSequence(coq =>
        switch coq {
        | Command(command) => handler->handleCommand(command)
        //| Query(query) => handler->handleQuery(query)
        }
      )->Promise.map(Array.concatMany)
    | Error(#BadJoinRoomAddress(playerId, roomAddress)) =>
      Message(
        RoomJoinError({
          playerId: playerId->PlayerId.toInt,
          roomAddress: roomAddress,
          reason: #BAD_ADDRESS,
        }),
      )
      ->Array.just
      ->Promise.resolve
    }
  )
Enter fullscreen mode Exit fullscreen mode

Handlers are testable, not very convenient, but absolutely doable. The basic idea is to create a dedicated database for unit testing, which is erased and seed with demo data on each test case setup. Then, the test suite plays the role of the server app layer, asks to handle various messages, and checks handle results. Because handlers know nothing about hosting apps, the technique is straightforward. Such tests are slow, but they serve an important role in checking whether all layers are correctly glued and that IO works as expected with actual storage. In my case, handler tests comprise ~20% of all tests.

In summary, the handler layer is a reusable module that forwards all pure-functional work further to the service layer and, on its own, does the dirty stuff. It converts domain objects to DTO and back, reads storage to prepare input for the service layer, writes storage to save the service layer output.

Service layer

The service layer, also known as the “domain service layer,” is the first layer in the chain that is purely functional 🥰 It takes commands along with domain models, applies business logic, and produces events describing what happened. Here’s an example of such a service:

open SimpleTypes

// Services use domain models extensively
module Player = AxEngine.Player
module Room = LobbyDomain.Room
module RoomTable = LobbyDomain.RoomTable
module RoomPlayer = LobbyDomain.RoomPlayer

module Command = {
  type joinRoomTarget =
    | Existing(RoomTable.t)
    | New(Room.Address.t)

  // Commands are variants designating desired action.
  // Their parameters contain minimal context to perform this action.
  type t =
    | JoinRoom(RoomPlayer.t, joinRoomTarget)
    | LeaveAllRooms(RoomPlayer.t)
    | UpdateWaitMode(playerId, RoomPlayer.waitMode)
    | GoOffline(RoomPlayer.t)

  // Command might be rejected if it violates business contract
  type error = [#RoomFull | #BadNumberOfPlayers | #RoomDoesNotExist]
}

module Event = {
  // If command applied successfully, zero or more events reported
  // as happened. A caller is responsible to record/report these events.
  type t =
    | RoomCreated(Room.t)
    | PlayerJoinedRoom(playerId, Room.t)
    | PlayerLeftRoom(playerId, Room.t)
    | WaitModeUpdated(playerId, RoomPlayer.waitMode)
    | RoomFilledUp(RoomTable.t)
}

// The essense of a service
type execute = Command.t => result<array<Event.t>, Command.error>

//=====================================================================
// Implementation
//=====================================================================

let execute: execute = command =>
  switch command {
  | JoinRoom(roomPlayer, New(FriendsRoom(_) as address)) =>
    /* ... call domain models’ functions ... */

  | JoinRoom(roomPlayer, New(GlobalRoomForTwo as address)) =>
    /* ... call domain models’ functions ... */

  | JoinRoom(roomPlayer, Existing(roomTable)) =>
    /* ... call domain models’ functions ... */

  | JoinRoom(_, New(GlobalRoom(_))) =>
    /* ... call domain models’ functions ... */

  | UpdateWaitMode(playerId, waitMode) =>
    /* ... call domain models’ functions ... */

  | GoOffline(roomPlayer) =>
    /* ... call domain models’ functions ... */

  | LeaveAllRooms(roomPlayer) =>
    /* ... call domain models’ functions ... */
  }
Enter fullscreen mode Exit fullscreen mode

The services are joyful to test. They have no side effects, neither they have impure dependencies. You make a command, give it to execute, and check the result. You might quickly craft dozens of tests for all the various flows and edge cases and be sure the business logic is rock solid.

You might wonder why this layer exists and what the purpose of the long variant switches is. After all, the handler layer might directly call domain models’ functions. The reason is composability. Different commands might produce the same events or intersecting sets of events. Similarly, various inbound messages can trigger the same commands. If not put in a dedicated service layer, the handler layer is going to blow up. However, if you have done the split this way, the service layer offloads the handler layer, and the latter is only required to produce commands and handle events.

Another reason lies in the canonical DDD methodology. What a system can do and what events it can produce is discussed with domain experts (developers talk to business people). The activity is known as “Event Storming.” And even if you don’t arrange such activity, understanding the possible ins and outs is likely to happen before you write an app. So, in practice, you typically create this layer and cover it with tests before the handler layer even appears.

In summary, the purpose of the service layer is to define how business works in terms of commands and events. The primary function is execute, which gets a command, domain models to operate upon, calls necessary domain functions over these models to actually apply business logic, and finally produces output in the form of events.

Domain layer

Finally, the domain layer is a place where the most exciting things happen. A domain is what makes your project do something valuable and unique. It delivers business value. More specifically, it defines objects (aka models) and data types along with functions operating on them that together describe the world your app works with.

  • If you make a game, the domain contains everything related to the rules and objects of the game. If you make an online store, the domain would include concepts of a cart, order, product, coupons, and functions related to checkout, product comparison, and so on.
  • If you make an online graphics editor, the domain would include all GFX math, paint tools, filter algorithms, etc, etc.

Here's just one module interface from the Future Corporations game to give you a rough idea of how domain objects and functions can look like. It is responsible for a particular game state management; it defines the rules of the game (highly inspired by Monopoly):

/*
 *
 * The central point of game logic engine
 *
 */
type t

module Cluster = Board.Cluster
module Timeout = Types.Timeout

// Creates a new game room
let make: (
  ~initialCash: Money.t=?,
  ~initialSalary: Money.t=?,
  ~bonusAmount: Money.t=?,
  ~actionTimeout: int=?,
  ~bidTimeout: int=?,
  ~turnMissesBeforeKnockout: int=?,
  ~skipWelcomeLogEntry: bool=?,
  int /* number of players */,
) => t

let getNumberOfPlayers: t => int

/*
  Returns a list like [0, 1, 2, 3] where number of elements is defined
  by the number of players in the game.
*/
let listPlayers: t => list<Player.t>

let countPlayersAt: (t, Location.t) => int

let getPlayerState: (t, Player.t) => Player.State.t

let getWhoTurns: t => Player.t

let getActivePlayerNextTo: (t, Player.t) => Player.t

let getSalary: t => Money.t

let getAuction: t => option<Auction.t>
let hasAuction: t => bool

let getCellOwner: (t, Location.t) => option<Player.t>

let getMonopolyOwner: (t, Cluster.t) => option<Player.t>

let getMortgageLimit: (t, ~player: Player.t) => Money.t

// Returns property upgrade level (0 to 5); returns 0 if not a property
let getPropertyLevel: (t, Location.t) => int

let maySellCell: (t, Location.t, ~player: Player.t) => bool
let mayUpgradeProperty: (t, Location.t, ~player: Player.t) => bool
let mayDegradeProperty: (t, Location.t, ~player: Player.t) => bool

let isFinished: t => bool

// Winner goes at index #0, second place at #1, and so on
let getFinalRanks: t => option<list<Player.t>>

let getTimeoutId: t => Timeout.id
let getTimeoutDuration: t => option<int>
let getTimeoutSettling: t => int
let getTimeoutPlayers: t => Player.Set.t

let getTurnMissesBeforeKnockout: t => option<int>

/* ====================================================================
 *
 * Game log manipulation
 *
 * ================================================================== */

/*
  Forcibly adds a new record to the log. Use to push non-game records and
  to consolidate the log when records are obtained via an alternative
  channel, such as network message
*/
let log: (t, Log.Record.t) => t

/*
  Steals log from the second game argument into the first inserting it
  before the current records. Useful to hydrate a game update that came
  with partial log only
*/
let prependLogFrom: (t, t) => t

let getLog: t => Log.t
let clearLog: t => t

/* ====================================================================
 *
 * Actions
 *
 * ================================================================== */

let applyDiceRoll: (t, (int, int), ~player: Player.t) => t
let applyChance: (t, Chance.t, ~player: Player.t) => t

let passAuction: (t, ~player: Player.t) => t
let startAuction: (t, ~player: Player.t) => t
let giveUpAuction: (t, ~player: Player.t) => t

/*
  Makes a bid on the current auction. The bid is expected to be absolute,
  that is offered price, *not* price delta
*/
let bidAuction: (t, Money.t, ~player: Player.t) => t

let upgradeProperty: (t, Location.t, ~player: Player.t) => t
let degradeProperty: (t, Location.t, ~player: Player.t) => t
let sellOwnCell: (t, Location.t, ~player: Player.t) => t

let acceptChance: (t, ~player: Player.t) => t
let rejectChance: (t, ~player: Player.t) => t

let resign: (t, ~player: Player.t) => t

let handleTimeout:
  (
    t,
    Timeout.id,
    ~roll: unit => (int, int),
    ~takeChance: unit => Chance.t
  ) => t

// Alternative interface (used to play with bot)
let act: (
  t,
  Player.Action.t,
  ~player: Player.t,
  ~roll: unit => (int, int),
  ~takeChance: unit => Chance.t,
) => t
Enter fullscreen mode Exit fullscreen mode

One crucial requirement to everything in the domain layer is functional purity. In other words, domain layer functions cannot have side effects, perform IO, get the current time, generate random numbers, or do anything which can lead to behavior differences depending on the environment, current time, or process state. Neither they may take impure function arguments or return impure functions. This requirement may sound unreasonably strict, but it is pretty easy to satisfy once you see a pattern:

  1. When a domain function wants a value or object that cannot be obtained purely (say, the current time), add a parameter to this function that brings this value to the function scope (e.g., ~now: Js.Date.t). Let the caller do the dirty work.
  2. When a domain function wants to perform a side effect, return a simple record/object that describes the desired effect. Let the caller apply it accordingly.

In the example provided, take a look at the functions applyDiceRoll and applyChance, for example. Instead of implicit random number generation when a player is going to roll a dice or pick a chance card, the engine “waits” for the caller to tell the outcome explicitly.

Why so? The answer is simple. As long as you keep your domain pure, you can test it effectively. Pure functions never require mocks, stubs, or any hacks. You literally test what you test, no less, no more. And as the domain layer contains most of the logic intrinsic to your application, keeping it pure makes it easy to have a comprehensive test suite covering all the business scenarios. In my particular case, more than 50% of the test code goes here. This gives you high confidence in your product. Multiply this by the sound and robust type system of ReScript, and you get an unbreakable core!

A very attentive reader could spot purity violation in functions handleTimeout and act. Although they have no side effects, they have impure functions as parameters roll and takeChance. Yes, my fault. They were written before all the ideas of layer restrictions were crystallized in my head. As a consequence, this couple often causes pain while testing and composing. If I implement these functions today, instead of taking impure random-generation functions as arguments, I’d provide them with some kind of pure object called “Entropy” that already contains a sequence of a few specific pre-generated values with a knowingly enough length to perform any chain of a single turn game actions.

In summary, the domain layer is your application heart. It is a collection of types, objects, functions that effectively do the job. The domain layer has no artificial requirements for its interface. You are free to shape and organize it in whatever way that makes more sense. The only vital requirement is functional purity, allowing heavy unit testing.

Shared domain

You might note that I put the domain layer out of the “back-end” box on the original diagram. That’s because some portions of domain code can be shared across the back-end and front-end. And that’s where full-stack strategy really shines 🌟

For example, the server and the mobile app use the same game domain logic in the Future Corporations game. On the server, it is used for multiplayer games (that is, the server updates the games), and in the app, absolutely the same code is used to run offline games against AI or friends in hot-seat mode.

In the Figuro laser cut service, the same code is used by the client to compute manufacturing price instantly (without hitting a server) as a user uploads a new drawing or chooses another material and on the server at checkout to verify prices and avoid potential fraud.

Cool eh? 😎 This would be impossible if front and back were developed in different languages.

I think it’s clear that not all the domain code is reusable on both sides. So, most of the domain code is indeed belongs to the server. I just wanted to emphasize the possibility of uniform client/server-side usage in the cases when it makes sense.

Back-end overview

Backend summary

Let’s repeat in short what layers the back-end has and how they communicate.

  1. Gateway. The only thing available publicly. Forwards messages forth and back between clients and server apps. For web apps, you’d likely use a ready-made solution like NGINX or Traefik. For real-time apps based on websockets, this would probably be a thin OS service connected to some message broker.
  2. Server app. A deployment and replication unit. Hosts all further layers inside itself. The primary responsibility is to listen for messages, convert wire data to DTO and back, perform logging, read configs, keep connections to external services. Hardly testable.
  3. Handler. Communicates to external services and databases, converts DTO to domain objects and back, interprets inbound messages to commands and events to outbound messages. Quite testable using external service stubs and dedicated unit-test databases.
  4. Service. Applies commands to domain objects and generates command consequence events. Functionally pure. Directly testable with no hacks.
  5. Domain. Business domain objects and functions. No restrictions on API. Functionally pure. Directly testable with no hacks.

One sandwich with onion, please

What else besides software architecture have layers? Yep, onions and sandwiches. That’s why they became metaphors for software engineering. And if I happened to explain the concepts clearly, you almost already know what “Onion architecture” and “Impure-pure-impure sandwich” are. These terms are just another view on the things already explained. Let’s repack the concepts so that you can keep the talk with your mates in a bar.

— We use onion architecture 🤘

— Me too!

Onion

The point of all buzz around the onion architecture is having well-defined layers with a unified coupling direction. We have this. Domain knows nothing about the service layer; the service layer knows nothing about handlers; the handlers don't know anything about the app layer. Onion done!

Topics to discuss in a bar: do external services belong to the onion skin, and how deep they might be coupled with outer layers?


— We make impure-pure-impure sandwiches 🤤

— Me too!

Sandwich

The metaphor about the sandwich is all about shifting side effects to an operation boundary so that the middle layer (the stuffing) might be easily testable. We have this. The service app layer is highly impure and hard to test, the handlers are impure too (yet testable), and the service layer, along with the domain layer, is pure, juicy, and easily testable.

Topics to discuss in a bar: is it OK to say that processing with FFmpeg or ImageMagick may belong to the stuffing layer? Does the answer change if I won’t call them as a subprocess but use NodeJS bindings and make the transform in-process?

Final words

The architecture described in this article is just one way to do things. You might follow it, or follow it partially, or ignore it and build your project in a completely other fashion. I aimed to describe a possible way to structure code sustainable enough to survive medium-scale. If you’re making a small-scale intranet utility, it is OK to mash all the logic in a single layer. If you’re big and looking for super-hi-load recipes, you don’t read these words. And if you are a solo developer or a small dev team member planning to rule the world, I’d be happy if I helped a little.

A minute of insolence: if you like the article, I’d appreciate it if you rate Future Corporations on Google Play. It’s my side project, but I put much effort into it. And because I’m stupid in marketing, one ad campaign brought me a pack of 1-star ratings. I would be happy to recover, but only if you really like the game.

Cross-post

This post was originally published at https://fullsteak.dev/posts/fullstack-rescript-architecture-overview

Top comments (0)