Professional Services

Charnwood Systems: a shopfront built for speed, reach, and reliability

Charnwood Systems had no website and no way to receive enquiries online. We became our own first client and built charnwoodsystems.io through our own engagement methodology. The result is a six-page site that is lightning fast, reliable, and low cost to operate, with sub-second page loads and a score of 100 out of 100 on every Google Lighthouse metric (the industry standard for measuring website quality).

Solutions
Application & Product Engineering Platforms & User Experience Infrastructure Delivery & DevOps Architecture & Technical Strategy
Services
Consultation Design Build
Market
Industry: Professional Services Sector: Software Consulting Role: Founder Role: Director
Technologies
Language: Rust Database: PostgreSQL Platform: Fly.io Framework: Axum

Background

We built a marketing website for a technology consulting business. It has six pages: a homepage, solutions, consultation form, contact, work with us, and opportunities. It does not need actor-model event sourcing. It does not need event journalling, aggregate supervisors, or optimistic concurrency control on a Postgres journal table.

We built it that way anyway. This is absolutely overkill for a simple website, and we knew that going in. We cannot over-emphasise that point: the architecture we chose is grossly more than a site of this size requires, and we chose it deliberately.

The honest reason is that we wanted to see how durable, reliable, and fast we could make a small web application if we applied the architecture we intended to use for more complex applications at scale. If the site does expand (a CMS, third-party integrations, auditability requirements), the hexagonal architecture makes it straightforward to plug in new adapters and accommodate new concerns without restructuring what already works. It is a pattern we would likely use to some degree in the future for more complex applications at scale.

Rust was chosen for its speed and reliability. Compile-time safety catches entire categories of bugs before the code runs. Zero-cost abstractions mean high-level patterns carry no runtime overhead. The absence of a garbage collector eliminates pause-time unpredictability. And the compiler produces a single static binary, which simplifies deployment and reduces the operational surface. The stack is Rust (2021 edition) on Axum with Tower middleware, Askama compile-time templates, PostgreSQL via SQLx with compile-time checked queries, deployed on Fly.io in the Sydney region via Docker multi-stage builds and GitHub Actions CI/CD.

That level of engineering depth is also more achievable than it would have been a few years ago. The entire build was augmented with AI, which meant patterns that might have taken weeks to implement and validate by hand were explored, tested, and refined in a fraction of the time. When the cost of engineering depth drops that far, the calculus on what counts as "over-engineered" changes.

Constraints

Three constraints shaped the build. Each one is modest on its own. Holding all three simultaneously is where the engineering content lives.

Single-request delivery. Every page is delivered as one HTTP response. CSS, JavaScript, and SVG icons are inlined directly into the HTML. No external asset requests on the critical render path beyond favicon variants. No waterfall, no CDN dependency, no asset pipeline.

Progressive enhancement. All forms and navigation work fully with JavaScript disabled. A handful of small JS modules (morph.js, events.js, theme-init.js, theme-controls.js) enhance smoothness but never enable functionality. If a corporate firewall strips scripts or a screen reader ignores them, the site works the same.

Durable state. Every stateful surface (session preferences, consent decisions, form submissions) must survive a process restart cleanly and be recoverable by replaying a durable journal. No silent data loss, no "sorry, please resubmit."

Architecture

The application is organised as hexagonal bounded contexts. Three are stateful: session, consultation, and work_with_us. Each follows the same internal shape: a domain/ layer holding commands, events, queries, state, and port traits; an actor/ layer holding a long-lived tokio task, its typed handle, and a supervisor; and an adapters/outbound/ layer holding the Postgres journal and snapshot implementations. A fourth context, content, is stateless: it holds page handlers, templates, and the rendering pipeline. A geoip actor wraps a local MaxMind reader for region-based consent defaults. consent is a cross-cutting policy layer.

Each stateful aggregate runs as a single tokio task that owns its state exclusively. All mutations and reads go through a typed mpsc message channel. The actor's receive loop processes one message at a time. Here is the session actor's message type, which is representative of all three:

pub enum SessionMessage {
    Command  { cmd: SessionCommand, reply: oneshot::Sender<Result<CommandResult, SessionCommandError>> },
    Query    { query: SessionQuery, reply: oneshot::Sender<SessionView> },
    Shutdown,
}

On a Command, the actor calls state.decide(cmd) to produce new events (or reject the command), appends them to the Postgres journal with an (aggregate_id, sequence) primary key for optimistic concurrency, applies them to in-memory state, and replies. Every twentieth event triggers a snapshot write. On spawn, the actor loads the newest snapshot and replays any events after it.

A supervisor per bounded context manages actor lifecycle. It holds a HashMap<Uuid, ChildEntry> of active actors, routes GetOrSpawn requests (spawning if absent), evicts idle children after a thirty-minute TTL (flushing a snapshot on eviction), and wraps each child's run() in catch_unwind so a panic is logged and removed rather than killing the runtime. On SIGTERM, the supervisor drains all children and flushes unsaved snapshots within a deadline.

The rendering pipeline assembles each page by running an Askama template, collecting the CSS fragments used by components on that page, and inlining them alongside JavaScript modules and SVG icons into the returned HTML. One response per page, no asset waterfall.

Trade-offs

Actor-per-aggregate. Each stateful unit runs as its own tokio task, processing one message at a time. This makes concurrency invariants trivial to enforce and gives us an ordered audit trail and cheap restarts for free. For a six-page site, per-request handlers reading Postgres directly would have been simpler and entirely sufficient. The actor model is part of the over-engineering; we used it because these are patterns we would likely use for more complex applications at scale, and the cost of the extra machinery (supervisors, panic watchdogs, graceful shutdown) was low enough to justify exploring here.

What we gained from single-request delivery: A deploy replaces one HTML document per route and is done. No hash manifests, no cache-bust coordination, no stale-asset bugs after a deploy. Page weight landed between 110 and 125 KB uncompressed per page. To put that in perspective: a single hero image on most marketing websites is larger than our entire page, including all the CSS, JavaScript, and icons.

What we paid: Browsers cannot share cached CSS across navigations. Some would argue that on a larger site with fifty or more pages sharing the same stylesheet, re-sending styles with every response carries a meaningful bandwidth cost. We think the durability and speed the design provides outweigh that concern. There is also further room to optimise: the inlined CSS and HTML could be minified, and e-tag caching would let the browser skip re-downloading pages that have not changed since the last visit. Neither optimisation has been needed yet.

What we gained from server-side authority: One source of truth, no client-state drift to debug. Progressive enhancement falls out of the same decision: if the server renders every page without JavaScript, the site works on degraded connections, corporate networks that block scripts, and screen readers.

What we paid: A round-trip for every interaction. Acceptable at a warm TTFB of 200 to 300 milliseconds; would need reconsideration for a highly interactive interface.

Results

Every page scores 100 out of 100 on Lighthouse across Performance, Accessibility, Best Practices, and SEO (excluding the one deprecated API used by LinkedIn tracking scripts). That is a perfect score on the industry-standard audit tool, on every page, not just the homepage.

Pages weigh 110 to 125 KB uncompressed. For context, the average web page today is over 2 MB (per HTTP Archive). Our heaviest page is about one-sixteenth of that.

Warm time-to-first-byte from Perth to Sydney (over the open internet, not a local network) sits between 185 and 396 milliseconds, with typical requests landing around 200 to 300ms. Full page transfer completes in 370 to 612 milliseconds across all six pages. Sub-second page loads, every page, from the other side of the continent.

Zero external asset requests on the critical render path. The browser makes one HTTP request and gets the complete page.

The codebase carries 240+ unit and integration tests across 34 files, weighted toward the domain layer (pure and exhaustively covered) and the adapter boundary (template rendering, journal append-and-replay, snapshot save-and-load). On top of that, 46 Playwright end-to-end tests across 6 spec files drive the full stack through a real browser: forms, navigation, theme toggle, consent banner (with and without JavaScript), and an accessibility suite that runs on every build.

That is a ridiculous number of tests for a six-page website. It is also entirely feasible when the development process is augmented with AI. Tests that would have been tedious to write by hand become straightforward to produce, maintain, and extend, which changes the economics of coverage from "how much can we afford" to "why would we not."

Retrospective

What went well. The actor model made concurrency straightforward: one message at a time, invariants enforced in one place, no surprises. Single-request delivery removed an entire class of asset-pipeline bugs we would otherwise own. No hash manifests, no cache busters, no invalidation story after a deploy. Askama's compile-time template type-checking caught template-versus-view-model mismatches during the build, before anything reached a running server.

Where we got lucky. The single-request delivery choice was driven by a no-waterfall constraint, not by cache-correctness concerns. It turned out to also remove an entire category of cache-invalidation bugs we would have hit if CSS had been bundled into a separate file. We did not see this benefit at design time.

The MaxMind GeoIP database is a local file rather than a network call, which turned out to matter because the GeoIP actor has no latency budget and we did not plan for one. If the GeoIP lookup had required a network round-trip, we would have needed to redesign the request path or accept a slower consent-banner render.


Want to work on projects like this, or have one you want built this way?

Curious what we solve for? Explore solutions.