fn(args, deps) and Hexagonal Architecture
17 Mar 2026Hexagonal architecture often gets introduced with a lot of diagrams, vocabulary, and ceremony.
Ports. Adapters. Application core. Inbound and outbound boundaries.
That framing is useful, but it can also make the idea feel more abstract than it really is.
A simpler way to understand it is this:
Hexagonal architecture is mostly about keeping business logic independent from delivery and infrastructure details.
One practical way to do that in plain TypeScript is this shape:
fn(args, deps);
Where args is the input for this use case, and deps is the set of external capabilities it needs.
You can read that as: data in, capabilities in.
In plain TypeScript,
fn(args, deps)is a simple way to implement the core idea of hexagonal architecture without turning the pattern into ceremony.
The real point of hexagonal architecture
A lot of explanations make hexagonal architecture sound like the point is drawing a boundary around "the domain."
That is not wrong, but it is incomplete.
The real point is to stop business logic from being tangled up with transport, frameworks, and infrastructure.
You do not want your core use case to care whether it was called from:
- HTTP
- a CLI
- a queue consumer
- a cron job
- a test
And you do not want it tightly coupled to:
- a specific database client
- a specific mail provider
- a framework request object
- a logger singleton
- process-wide global state
That is the architectural problem hexagonal architecture is trying to solve.
A use case can just be a function
In plain TypeScript, a use case does not need to be a class to have a boundary.
It can just be a function.
type CreateUserArgs = {
name: string;
email: string;
};
type CreateUserDeps = {
saveUser: (input: { name: string; email: string }) => Promise<User>;
sendWelcomeEmail: (user: User) => Promise<void>;
log: (message: string, meta?: unknown) => void;
};
export async function createUser(args: CreateUserArgs, deps: CreateUserDeps) {
deps.log('Creating user', { email: args.email });
const user = await deps.saveUser(args);
await deps.sendWelcomeEmail(user);
return user;
}
That function already has the important boundary:
argsis what the use case needs from the callerdepsis what the use case needs from the outside world
That is a very direct way to express the "inside versus outside" split that hexagonal architecture cares about.
args and deps map cleanly to the hexagon
This is the useful connection.
In hexagonal architecture, people often talk about:
- inbound adapters
- outbound adapters
- ports
- the application core
Those ideas can sound bigger than they are.
With fn(args, deps), the mapping is usually straightforward:
argscomes from an inbound adapterdepsis implemented by outbound adapters- the function body is the application core
- the function signature is the boundary
For example:
- an HTTP handler can translate a request into
args - a Postgres repository can implement part of
deps - an email provider can implement part of
deps - the use case itself stays unaware of Express, Fastify, Postgres, or SES
That is hexagonal architecture in practice, not just in a diagram.
The HTTP layer becomes an adapter, not the center
Here is a simple HTTP adapter:
import { createUser } from '../application/create-user';
import { makeCreateUserDeps } from '../composition-root';
export async function postUsersHandler(req: Request, res: Response) {
const deps = makeCreateUserDeps();
const user = await createUser(
{
name: req.body.name,
email: req.body.email,
},
deps,
);
res.status(201).json({
id: user.id,
name: user.name,
email: user.email,
});
}
The handler does three things:
- reads transport-specific input
- calls the use case
- translates the result back to transport-specific output
That is adapter work.
The business logic is not in the handler. The handler is not the center of the system. It is just one way into the core.
That is one of the biggest practical wins of hexagonal architecture: the delivery mechanism becomes replaceable.