Clean Architecture in an Astro Site (Yes, Really)
"Astro is for content sites — why would you put Clean Architecture in it?" Because the moment your in-memory data becomes a real API, the question stops being rhetorical. Here's how this very portfolio layers presentation, domain, and data so the framework stays a replaceable detail and the business rules never learn it's Astro at all.

The first reaction I get when someone opens this repository is a raised eyebrow. “It’s a portfolio. Four sections and a contact link. Why is there a domain/ folder? Why is there dependency injection? Astro renders Markdown — what are you defending against?”
It’s a fair objection, and I want to take it seriously instead of waving it away with “best practices”. Because most of the time, when people bolt Clean Architecture onto a small site, they’re cargo-culting — copying the folder names without the reasoning. The layers become decoration.
So this article is the honest version. Not “here’s the textbook diagram”, but “here’s the actual code in this site, here’s exactly what each layer buys, and here’s where I’d tell you not to bother”. The site you’re reading runs on what follows.
The objection, stated fairly
Astro is content-first and ships zero JavaScript by default. For a brochure site that genuinely is four static sections, layering is overkill — I’ll say that plainly so you trust me when I say the opposite later.
But this portfolio has a tell. Every section’s data comes from an in-memory repository today:
locator.bindLazySingleton(ProfileRepository, () => new InMemoryProfileRepository());InMemory. That word is a promise to my future self: this is a placeholder for an HTTP call to a CMS or an API that doesn’t exist yet. The whole bet of the architecture is one sentence:
The day
InMemoryProfileRepositorybecomesHttpProfileRepository, nothing outside the data layer should have to change.
If that sentence holds, the layering paid for itself. If I have to touch a page, a section, or a use case to swap the data source, then the critics were right and I built a museum. Everything below is in service of making that one sentence true.
The dependency rule, drawn on this repo
Clean Architecture has exactly one non-negotiable law: source code dependencies point inward. Outer layers know about inner layers; inner layers know nothing about the outer ones.
This site has three layers plus shared infrastructure:
src/├── presentation/ → renders. Astro pages, sections, React islands.├── domain/ → the rules. Entities, value objects, use cases, repository contracts.├── data/ → the implementations. In-memory repos today, HTTP tomorrow.└── shared/ → infrastructure. The DI container, base classes.The arrows only go one way: presentation → domain and data → domain. Domain depends on nothing — not on Astro, not on React, not on Node. You can prove it mechanically: open any file under domain/ and you will not find a framework import. That’s not a guideline I hope people follow; it’s a property you can grep for.
Everything else in this article is just four files showing those arrows in action, from the outside in.
The page is a thin shell
Here is the entire home page. Not an excerpt — the whole file:
---import { loadHomePage } from '@presentation/pages/home/HomePagePresenter';
import BaseLayout from '@presentation/layouts/BaseLayout/BaseLayout.astro';import HeroSection from '@presentation/sections/HeroSection/HeroSection.astro';// ...other sections
const { profile, careerEntries, projects, articles } = await loadHomePage();---
<BaseLayout> <HeroSection profile={profile} /> <CareerSection entries={careerEntries} /> <ProjectsSection projects={projects} /> <ArticlesSection articles={articles} /></BaseLayout>Look at what the page does not do. It doesn’t new a repository. It doesn’t know data is in-memory. It doesn’t resolve use cases, catch errors, or transform anything. It calls one function, destructures the result, and hands typed data to sections.
In Astro terms, the page is a router. That’s the role I want it to have. The framework owns routing; my code owns everything routing leads to. If I move from Astro to anything else tomorrow, this 12-line file is the only thing in this flow that gets rewritten, because it’s the only part that’s framework-specific by design.
The presenter resolves the use cases
The page delegated to loadHomePage. That lives in the presentation layer too, but on the logic side of the line — this codebase keeps render and logic in separate files, always. The page renders; the presenter thinks.
export async function loadHomePage(): Promise<HomePageViewModel> { const locator = bootstrap();
const [profile, careerEntries, projects, articles] = await Promise.all([ locator.get(GetProfile).execute(), locator.get(GetCareerTimeline).execute(), locator.get(GetFeaturedProjects).execute(), locator.get(GetRecentArticles).execute(), ]);
return { profile, careerEntries, projects, articles };}Three things earn their place here:
bootstrap()wires the world, then the presenter asks for use cases by name. The presenter never constructs aGetProfileor knows what repository feeds it.Promise.all— four independent reads run concurrently. Today they resolve instantly from memory. The day they’re network calls, this code is already correct without a single edit. That’s the bet paying off in advance.- It returns a typed
ViewModelof domain entities. Sections receiveProfile,CareerEntry[], etc. — never raw JSON, never anany.
I’ll name the pattern honestly, because the precise word matters here: the presenter is doing service location — it asks a container for dependencies at runtime. Service location and dependency injection are not the same thing, and people who know the difference will notice. I’ll come back to why this specific seam is the right place to allow it. Hold that thought.
The use case knows nothing about Astro
Follow GetProfile inward and you reach the domain. This is the whole class:
import { Profile } from '@domain/about/entities/Profile';import { ProfileRepository } from '@domain/about/repositories/ProfileRepository';
export class GetProfile { constructor(private readonly repository: ProfileRepository) {}
async execute(): Promise<Profile> { return this.repository.findOne(); }}For a portfolio, GetProfile is almost comically thin — it forwards one call. I want to defend that thinness rather than apologize for it, because the shape is the point, not the line count. The day a profile needs a computed “years of experience”, a derived availability flag, or a merge of two sources, the home for that logic already exists, already has its dependency injected, and already has a name. I’m not creating the seam under deadline pressure; I’m filling a seam that’s been waiting.
And notice the constructor: it receives a ProfileRepository, which is an abstract class — a contract, not an implementation. This file imports nothing from data/, nothing from Astro, nothing from Node. tsc will happily compile it in a project where Astro isn’t even installed. That’s not an accident; it’s the dependency rule made physical.
The repository is a contract, not a class
ProfileRepository is deliberately almost empty:
import { Profile } from '@domain/about/entities/Profile';
export abstract class ProfileRepository { abstract findOne(): Promise<Profile>;}The domain declares what it needs — “give me a profile” — and refuses to care how. The how lives one layer out, in data/, and this is where the entire bet gets settled:
interface ProfileDB { _id: string; name: string; role: string; // ...the raw, external shape}
export class InMemoryProfileRepository extends ProfileRepository { private readonly profileDB: ProfileDB = { _id: 'francisco-rodriguez', name: 'Francisco Rodríguez', role: 'Frontend Tech Lead', // ... };
async findOne(): Promise<Profile> { return this.mapToDomain(this.profileDB); }
protected mapToDomain(raw: ProfileDB): Profile { return Profile.create({ id: raw._id, name: raw.name, role: raw.role, // ... }); }}Two details do all the work:
ProfileDBis a separate type from the domain shape, and it uses_id. That_idis not a typo — it’s a deliberate fingerprint of an external world (a MongoDB document, a REST payload). The domain doesn’t speak_id; it speaksid. Keeping the shapes distinct, even when they look almost identical today, is what keeps the boundary real instead of aspirational.mapToDomainis an anti-corruption layer. It’s the one and only place where “the shape the outside world hands me” becomes “the shape my domain trusts”. Every public method funnels through it.
Now the payoff, concretely. Swapping to a real API is this diff and nothing else:
export class HttpProfileRepository extends ProfileRepository { async findOne(): Promise<Profile> { const raw = await fetch('/api/profile').then((r) => r.json()); return this.mapToDomain(raw); }
protected mapToDomain(raw: ProfileDB): Profile { return Profile.create({ id: raw._id, name: raw.name, /* ... */ }); }}Then one line in the composition root points at the new class. The page didn’t move. The presenter didn’t move. GetProfile didn’t move. Profile didn’t move. That’s the sentence from the opening, kept.
Wiring without a framework
So who connects an abstract ProfileRepository to a concrete InMemoryProfileRepository? Not a DI framework — this is Astro, and dragging in InversifyJS or tsyringe would be exactly the kind of overkill the critics warned about. The site uses a hand-rolled container, small enough to read in one sitting:
type Type<T> = abstract new (...args: any[]) => T;
export class DependencyLocator { private factories = new Map<Token<any>, Binder<any>>(); private lazySingletons = new Map<Token<any>, any>();
public get<T>(token: Type<T> | string): T { const factory = this.factories.get(token); if (!factory) { const name = typeof token === 'string' ? token : token.name; throw new Error(`Dependency ${name} is not registered`); }
if (factory.type === 'lazySingleton') { if (!this.lazySingletons.has(token)) { this.lazySingletons.set(token, factory.fn()); } return this.lazySingletons.get(token); }
return factory.fn(); }
public bindLazySingleton<T>(token: Type<T> | string, fn: () => T) { this.factories.set(token, { type: 'lazySingleton', fn }); } // bindFactory and clear omitted}Three design choices are worth pausing on:
The token is an abstract class, not an interface. type Type<T> = abstract new (...) => T is what lets me pass ProfileRepository itself as the key. This is the reason the contracts are abstract classes and not TypeScript interfaces: interfaces vanish at compile time, so they can’t be a runtime lookup key. An abstract class is both a compile-time type and a runtime value. One symbol, two jobs, zero stringly-typed tokens to typo.
Lazy singletons check has, not truthiness. Earlier versions wrote lazySingletons.get(token) || factory.fn(). That’s a trap: a factory that legitimately returns a falsy value (0, '', false) would be re-run on every get, silently breaking the singleton guarantee. Repositories are objects, so it never bit me — but “it doesn’t bite me” is not the standard for code you publish. The .has(token) check is correct for any value, and there’s a test pinning it so it stays that way.
Bootstrap is idempotent. Every Astro page calls bootstrap() in its presenter, and a module-level flag makes every call after the first a no-op:
export function bootstrap(): DependencyLocator { const locator = DependencyLocator.getInstance(); if (bootstrapped) return locator;
locator.bindLazySingleton(ProfileRepository, () => new InMemoryProfileRepository()); locator.bindFactory(GetProfile, () => new GetProfile(locator.get(ProfileRepository))); // ...the rest of the wiring
bootstrapped = true; return locator;}Repositories are bound as lazy singletons (one instance, reused — they may hold connections or caches). Use cases are bound as factories (cheap, stateless, a fresh one each time). And here, finally, is the honest answer to the “service locator vs DI” objection I parked earlier.
Notice new GetProfile(locator.get(ProfileRepository)). The use case receives its dependency through its constructor — that’s genuine dependency injection. GetProfile never sees the container; it can’t even reference it. The only code that talks to the locator is the composition root and the presenters — the outermost edge of the app, the place whose entire job is wiring. Service location confined to the composition boundary, constructor injection everywhere inward, is a deliberate and well-understood pattern. The anti-pattern is when the locator leaks into your domain so every class reaches into a global bag of dependencies. That never happens here, and now you can verify the claim instead of taking my word for it.
“But isn’t this overkill for a portfolio?”
Let me give the critics their due, because the honest answer is: partly, yes.
For four static sections that will never change their data source, this is more structure than the problem demands. If that were the whole story, I’d have written four .astro files with hardcoded content and gone outside.
But two things make the structure earn its rent even here:
- The migration is already paid for. When the profile, projects, and articles move behind a real API — and they will — the change is one line per repository in the composition root. No page, section, presenter, use case, or entity is touched. I’m not predicting I’ll probably need this; the
InMemoryprefix is me committing that I will. - Testing is trivial.
DependencyLocator.clear()resets the world between tests. Use cases take their dependency as a constructor argument, so a test hands them a fake repository directly — no module mocking, no framework gymnastics. The domain has zero framework imports, so domain tests run in milliseconds with nothing booted.
And the honest “don’t bother” line, because an article that only sells is propaganda: if you’re building a genuinely static marketing page, a one-weekend landing site, or anything you’ll throw away — skip all of this. Layering is insurance, and insurance you’ll never claim is just cost. The skill isn’t applying Clean Architecture; it’s knowing when the bet is worth making.
For a portfolio I’ll maintain for years, that I want to migrate to a real backend without a rewrite, that I use as a reference for how I think about structure — the bet is worth it. So the domain/ folder stays, and yes, really, it belongs there.
A framework is a detail — the most expensive detail to get wedded to, and the easiest to mistake for the architecture. Put your rules in the center, keep the arrows pointing inward, and the day you swap Astro for whatever comes next, the only thing you rewrite is the part that was always meant to be replaceable.