β€’

tech

Core principles for software that scales (in practice)

Implementing large scale software projects requires following some core principles: local reasoning, error handling, testability, debugging, observability, state management, and how these all work in practice.


Sandro Maglione

Sandro Maglione

Software

Lately I am thinking about this more and more:

What are the principles to write code that scales? πŸ€”

Leaving aside the specific language, design patterns, and theory. What should a developer focus in practice when writing large software projects?

Some (initial) ideas:

  • πŸ“– Local reasoning
    • Modularity
    • Refactoring
  • 🚩 Error handling
  • πŸ§ͺ Testability
    • Configuration
  • πŸ”¬ Debugging/Observability
  • 🎨 State management
    • Data structures

This is how it looks like in practice πŸ‘‡


Local reasoning: the secret for software that scales

You work in a car factory. You role is to attach doors to the frame of the car:

  • You expect the car's frame to be delivered to you (input)
  • You perform your role (attach the door)
  • You produce a car with doors (output)

In this process you don't need to bother with anything else: get the frame, attach the door, move to the next frame.

This is what makes factories scale: local reasoning.

It works the same for code:

Local reasoning: property of some code wherein the correctness of the code can be inferred locally (under specified assumptions) without considering prior application state or all possible inputs

(Source)

/*
 * Can you tell what this function does, without reading any other part of the codebase?
 * - Get a `card` and `amount`
 * - Perform a payment to the given `card` for the given `amount`
 * - It requires a `PaymentService` to work (e.g. Stripe)
 * - It can fail if `card` is invalid or we are unable to check its validity
 * - When successful, it returns the details of the payment (`Payment`)
 * - It performs a side-effect (return type `Effect`)
*/
const makePayment = (params: { amount: number, card: Card }): Effect.Effect<
  Payment,
  InvalidCardError | CheckingCardError,
  PaymentService
>  => /// ...

There is more 🀩

Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights

Not convinced? Well, let me tell you more about it

Error handling

Error handling is not discussed enough 🀨

It's found and required in every software, but there is still a lot of confusion around it

In practice:

  • Returns errors as values
  • Propagate errors between functions
function getCreditCard(email: string): Effect.Effect<Card, CheckingCardError> {
  /// ...
}
 
function getIsCardValid(card: Card): Effect.Effect<true, InvalidCardError> {
  /// ...
}

/*
 * `getCreditCard` and `getIsCardValid` define their own errors (on their return type).
 *
 * Since `makePayment` calls both `getCreditCard` and `getIsCardValid`
 * those errors are propagated also in the return type of `makePayment` ☝️
*/
function makePayment(params: { amount: number, card: Card }): Effect.Effect<
  ...,
  InvalidCardError | CheckingCardError | ConnectionError
> {
  /// ...
}

Testability

Tests cannot be an afterthought πŸ’πŸΌβ€β™‚οΈ

Writing testable software requires thinking about the structure of your code from the start

In practice:

  • Pure functions: easy to (unit) test, same input always same output
  • Dependency injection (manage effects): when side effects are necessary, inject mock implementations
it("should return a valid SubscribeResponse when request successful", async () => {
  const response = await (
    await Server.main.pipe(
      Effect.provideService(
        Request,
        // πŸ‘‡ API request mocked locally
        new globalThis.Request("http://localhost:3000/", {
          body: JSON.stringify({ email: "" }),
          method: "POST",
        })
      ),

      // πŸ‘‡ Configuration parameters mocked locally
      Effect.provide(layerConfigProviderMock),
      
      Effect.runPromise
    )
  );

  // πŸ‘‡ Check response to local mock value
  expect(response).toStrictEqual(subscribeResponseMock);
});

Debugging/Observability

One thing is making code that compiles, another thing is verifying that it does what you expect (in every case).

Debugging is necessary: you cannot foresee everything that may happen.

When something doesn't work as expected, inspecting internal state at runtime becomes necessary 🚩

In practice:

  • Logging: not just text messages, but also timestamps, stack traces, relevant state, and more
  • Metrics: inspect performance, count function calls, track external requests
  • Tracing: complete overview of the lifetime of requests
Inspect every function call, how much time it takes, external requests, and more
Inspect every function call, how much time it takes, external requests, and more

State management

Values change: we want these changes to be performant, predictable, and easy to understand/visualize.

In practice:

  • Data structures: optimize performance based on the most common operations
  • State machines: define all possible states and make state transitions predictable and easy to visualize

You can read more about these topics below:


Regardless of programming language or area of expertise, these principles will always follow you everywhere πŸ‘»

These are underappreciated topics, I plan to cover each of them in more details (and in practice).

Stay tuned, this can become something big 🀫

See you next πŸ‘‹

Start here.

Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights

Not convinced? Well, let me tell you more about it