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
Concepts to understand about error handling π π Error vs Exception π Stack track π Error object π try/catch/finally π Union types (Option/Either) Error handling is at the core of every program Back to refine and master the basics πΒ Show more
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
> => /// ...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
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 π
