If I were to name two major factors that contribute to poor code quality, I would highlight: coupling and abstractions.
Coupling can be summarized as the degree of dependency one piece of code has on another. The less, the better. Some enthusiasts, obsessed with minimizing dependencies, even pioneered a nearly zero-coupling approach to coding: microservices. Initially, it seemed promising. However, some developers were dissatisfied with the need to duplicate code, leading them to create a /shared
directory for consolidating their reused code and importing it across their microservices.
On the other hand, abstraction is the process of identifying significant elements within mundane details and transforming them into an unintelligible mess that seldom functions as intended. An example of this is new QueryBuilder(new Select(Filter.ALL), new Repository("users"))
. The concept here is that you can effortlessly swap out one piece of code for another later on because you abstracted it well in advance. Clever, right? Except, you've likely forgotten how it was done, so you'll probably end up redoing everything from scratch anyway.
Hexagonal (or Ports & Adapters) Architecture embodies the latter philosophy. It segregates business logic from technical logic. The idea is that business logic uses ports (placeholders) for operations, and concrete implementations are defined in adapters later on.
// My port
type FetchBalance = () => Promise<number>;
// My business logic
const canGive = (amount: number) => async (fetch: FetchBalance) => {
const balance = await fetch();
if (amount <= balance) return true;
else return false;
}
// My adapter/technical logic
const fakeFetch: Fetch = () => Promise.resolve(100);
// My app
const program = canGive(10)(fakeFetch);
Nothing overly complex, right? But when is the right time to abstract? What if you're certain you'll always approach a task in a specific manner? Consider if you liberally use new Date()
and someone suggests: "Perhaps we could use moment
or Temporal
instead." It might prompt you to think about abstracting dates. In essence, even a simple array.map(...)
is technically an implementation of an iterator. In the future, there might be alternative methods, suggesting you could abstract some Iterator
functionality instead of directly using .map()
... Stop! Let me use the damn .map()
!
You see where I am going, right? When an architecture prompts more questions than it resolves, this is when I get some itching.
There should be a better way
Thus, we aim to isolate our code to avoid spaghetti code, ensure it's testable, reusable, etc., while avoiding unnecessary abstractions. If there's a simple, standard way of doing something, that's what we should use. We want to adhere to the principle of being "Open for extension" but "Closed for modification." If a new method for .map()
arises, it should be integrable in a plug-and-play fashion.
Let's try to organize our thoughts.
We aim to isolate our code to avoid spaghetti code, ensure it's testable, reusable, etc., while avoiding unnecessary abstractions. If there's a simple, standard way of doing something, that's what we should use. We want to adhere to the principle of being "Open for extension" but "Closed for modification." If a new method for .map()
arises, it should be integrable in a plug-and-play fashion.
Breaking down things
Imagine a basic program: a /login
endpoint that exchanges a code for an access token from a third party, then generates and returns our own access token to the user.
A straightforward initial version might look like this:
import axios from "axios";
import jwt from "jsonwebtoken";
const login = async (code: string): Promise<string> => {
const accessToken = (await axios.post("...", { code })).data;
return jwt.sign({ accessToken }, "");
}
For proponents of "Clean Code" / "Hexagonal Architecture," this could be alarming. The instinct might be to critique: "Why not just inject an HTTPClient and a JWTService?" Relax, I got you.
Indeed, dividing our code to reduce coupling is beneficial.
const exchangeCode = async (code: string) => (await axios.post("...", { code })).data;
const signAccessToken = (accessToken: string) => jwt.sign({ accessToken }, "");
const login = async (code: string): Promise<string> => {
const accessToken = await exchangeCode(code);
return signAccessToken(accessToken);
}
This approach feels more refined. It's also clearer that we have two types of functions: simple unit functions that could be swapped via a plugin, and a workflow that orchestrates these functions.
There is a really good way of representing workflows in programming: a finite state machine. We could envision our "login" function transitioning through states like "ReceivedCode" to "ReceivedAccessToken" to "ReceivedJwt".
type LoginState =
| { kind: "ReceivedCode", code: string }
| { kind: "ReceivedAccessToken", accessToken: string }
| { kind: "ReceivedJwt", jwt: string };
We then design our functions as transformers of this state.
const exchangeCode = async (state: LoginState & { kind: "ReceivedCode" }): LoginState & { "ReceivedAccessToken" } => ({
kind: "ReceivedAccessToken",
accessToken: (await axios.post("...", { code: state.code })).data,
});
By now, you might see the potential of this approach. Can you see where this is taking us?
We can conceptualize a program as a finite set of states and a collection of functions that evolve these states until a final outcome is reached. It's akin to an automaton where the gears interlock and propel the machine. We can interchange some gears - these intermediary functions - if needed, allowing for flexible code with minimal abstraction.
Let's wrap it up.
A new way of doing "Clean Architecture"?
type LoginState =
| { kind: "ReceivedCode"; code: string }
| { kind: "ReceivedAccessToken"; accessToken: string }
| { kind: "ReceivedJwt"; jwt: string };
const exchangeCode = async (
state: LoginState & { kind: "ReceivedCode" }
): Promise<LoginState> => ({
kind: "ReceivedAccessToken",
accessToken: await Promise.resolve("some-access-token"),
});
const signAccessToken = async (
state: LoginState & { kind: "ReceivedAccessToken" }
): Promise<LoginState> => ({
kind: "ReceivedJwt",
jwt: await Promise.resolve("some-jwt"),
});
const handlers = { exchangeCode, signAccessToken };
const login =
({ exchangeCode, signAccessToken } = handlers) =>
async (code: string) => {
let state: LoginState = { kind: "ReceivedCode", code };
while (state.kind !== "ReceivedJwt") {
switch (state.kind) {
case "ReceivedCode":
state = await exchangeCode(state);
break;
case "ReceivedAccessToken":
state = await signAccessToken(state);
break;
}
}
return state.jwt;
};
login()("123").then(console.log);
Conclusion
This strategy might seem akin to the ports and adapters model, but there's a distinction. Although we effectively inject our handlers in a similar manner, it's better conceived as customizing the current implementation rather than reserving space for future implementation. This approach maintains the benefits of low coupling and minimal abstraction. The introduction of state management not only simplifies complex scenarios but also naturally aligns code towards business logic. Notice how it would be difficult to have raw SQL for example in the middle of a state. Also, it would be quite easy to dissociate business logic handlers vs. technical logic ones.
This is how you get flexibility while always having a functional app and not an empty shell which is so abstracted that it basically never works.
If you found this discussion engaging, I encourage feedback or comments. I'm working towards a comprehensive guide on a "no-coupling and zero-abstraction" architecture. Let me know if this has piqued your interest!