The Principles of Hestia Architecture
Build the product once. Keep each target shell as small as its platform requires.
Most multi-platform codebases start with good intentions and end with a
shared/ folder that nobody can describe. Business rules leak into view
controllers. A vendor SDK type shows up in a domain model. “Portable” code
compiles for one platform and is patched for the others. The architecture that
was supposed to save effort becomes the reason every feature ships three times.
Hestia is a set of rules for building a product that is genuinely platform-neutral, plus the tooling to prove it. It is packaged as an Agent Skill so that an AI coding agent can apply the rules consistently, but the principles stand on their own. This post extracts them.
Throughout, the running example is a marketplace product for two targets,
android and ios, with Kotlin and Gradle recorded in hestia.json.
Illustrative snippets show the shape of code once you implement it. A target
in Hestia is a delivery destination that lands in a user’s hands: an Android
app, an iOS app, a macOS app, a Windows app, a browser app, a command-line
tool a person runs. It is not a compiler backend, a bytecode target, or a
runtime. Hestia itself is language-agnostic; the toolchain is whatever
initialization records.
The shape of a Hestia product
Hestia keeps product code platform-neutral and each target shell as thin as its platform requires. Dependencies always point inward: target code and plugin implementations depend on product modules; product modules never depend outward.

From the center outward:
- Domain model — entities, values, invariants. Depends on nothing outside itself. It may still define the vocabulary for effects a platform will provide later (session, clock, payments): product-owned types and capability shapes, never a target SDK import, and never a side effect performed by the entity itself.
- Domain services — only when logic spans entities and must not live in use-case coordination. Hestia does not invent these by default.
- Use cases and product presentation — the functions and classes that make the ubiquitous language operable. Plugin contracts (product-owned capability interfaces) live under the product root with the bounded context that needs them: named for the capability, never shaped like a vendor API. Use-case coordination calls those contracts and hands product-owned data to the domain model.
- Outside the product modules — adapters and tools.
- Target UI (
platform-ui): Android screens, iOS views, and other delivery surfaces that tell the product what to do. - Plugin implementations (
portable-plugin/platform-plugin): Room, Keychain, HTTP clients, analytics SDKs — they implement product-owned contracts and are injected inward. - Injector and platform-composition: composition roots that wire the product runtime and start the app.
- Target UI (
| Hestia role | Where it lives |
|---|---|
product | Product root — domain model, use cases, presentation, plugin contracts |
portable-plugin | Product root — plugin implementation that compiles for every target |
injector | Product root — composition root (not domain model) |
platform-ui | Target root — target UI |
platform-plugin | Target root — plugin implementation for one target |
platform-composition | Target root — creates adapters and starts the app |
A target is a delivery destination that lands in a user’s hands (Android app, iOS app, …) — not a compiler backend. Each target root holds that slice’s UI and target-specific plugins. The product root holds product modules, plus portable plugins and the injector that wire them. Inner layers never depend on outer ones.
1. The product root is named after the product
The first thing you see in a Hestia repository is the business, not the architecture.
marketplace/ <- product root: product modules + portable plugins + injector
catalog/ <- product module
marketplace-runtime/ <- injector (composition, not domain model)
android/ <- one root per declared target (UI + plugins)
ios/
docs/domain/ <- approved domain artifacts
hestia.json <- machine-readable architecture map
The product root uses the product’s real name. Hestia rejects architecture
labels as the root: core, shared, common, domain, model, entities,
use-cases, infrastructure, product, neutral, platform-neutral,
application, architecture, adapters, and similar. Those words describe
how the code is organized; they say nothing about what the software does.
This is Screaming Architecture applied at the top level: a new engineer should
be able to read the directory listing and know the business.
Each target gets exactly one sibling root, and each target is a real
destination that ships to a user. The target list comes from initialization
input; Hestia never assumes Android, iOS, or web. Partial-target names such as
mobile or apple are rejected outright, because a folder that covers “some
targets” is exactly where untested conditional code accumulates.
2. Classify every unit on two independent axes
When you pick up a type, Hestia asks two questions. They are not the same question.
- What job does it do? Domain model, use-case coordination, product presentation, plugin contract, plugin implementation, or composition.
- How many targets can it compile for? Every declared target, or exactly one.
“It compiles everywhere” does not mean “it is domain model.” A JSON mapper can
be portable and still be a plugin implementation. A checkout view-state machine
can be product presentation without being a catalog invariant. Treating
portability as a business-role test is how a shared/ folder becomes a dumping
ground.
The two answers combine into four placements:
| Job | One target | Every declared target |
|---|---|---|
| Domain model, use case, presentation | Forbidden. Split it. | Catalog, checkout view state |
| Plugin implementation, injector, UI, composition | Compose screen, RoomSessionStore | JSON mapper, kotlinx-datetime clock |
The forbidden cell is the whole point. Business logic that only compiles for one target is not “Android business logic.” It is product code with a target dependency still stuck in it. Extract the target part behind a product-owned plugin interface and move the rest under the product root.
3. The Placement Gate
The placement gate decides whether a declaration belongs under the product root or must stay outside as a plugin or target adapter. A coherent declaration lives under the product root when all four statements are true:
- It compiles without a target SDK or target-only library.
- It imports only product modules and the language standard library or compiler-shipped runtime.
- Its public interface exposes only product-owned types.
- Its tests run without a target runtime.
The absence of a target import is necessary but not sufficient. Names, types,
and behavior must also stay target-free. A class with no target imports can
still be target code if it is named ActivityResultHandler, assumes a main
thread, or hides an if (isAndroid) branch.
Two more rules shape how the gate is applied:
- Classify coherent declarations, not files. If a file mixes product and target behavior, split it. The neutral calculation moves to the product; the mapping that touches target types stays at the boundary.
- Extract the failing part, keep the rest. If a small piece of otherwise neutral logic needs a target capability, put that piece behind a product-owned plugin interface and keep everything else in the product.
4. Every effect goes through a product-owned capability
Plugin implementations never leak inward as a vendor type. Time, random values, identifiers, locale, time zone, storage, files, network, device information, analytics, logging, and cryptography are named as product-owned capability interfaces — types the product owns. Plugin modules implement those contracts; product code never imports the SDK that backs them.
Three rules govern these contracts:
Name the capability, never the vendor.
SessionStore.loadSession() good FirebasePlugin.getDocument() badUse product-owned request, result, error, and event types. The implementation translates every external model and error at its boundary. No
HttpResponse, noCursor, noFlow<T>from a third-party stream library, no DI container type in the contract surface.Own the contract in the business module that needs it. The interface lives with the bounded context that speaks it — often beside the model that needs the effect — not in a generic “ports” folder. Entities and values do not perform side effects; use-case coordination calls the capability and hands product-owned data to the domain model.
5. Third-party libraries are quarantined
Product business code has exactly zero third-party runtime dependencies. This
is not a guideline; the product role in hestia.json has an empty
thirdPartyDependencies list and the validator rejects anything else.
A library can appear in exactly three places:
| Where | Role | Condition |
|---|---|---|
| Under the product root | portable-plugin | The implementation compiles for every declared target |
| Under one target root | platform-plugin | The implementation needs that target |
| Under the product root | injector | Only a DI library, fully contained inside the module |
A portable plugin depends on its product contract and the wrapped library, and nothing else. It never becomes the public contract. In production, only the injector may import it; a dedicated test target may import it to run the shared contract suite.
6. Six roles, one dependency direction
Every build module is assigned one of six roles. Together they draw Hestia as a
dependency table: UI and plugin roles sit outside product modules, product
holds the domain and use cases, portable plugins and the injector are the other
product-root roles, and every legal edge points inward.
| Role | Job | Location | May depend on | Third-party dependencies |
|---|---|---|---|---|
product | Domain model, use cases, presentation, contracts | Product root | product | None |
portable-plugin | Plugin for every declared target | Product root | product | Wrapped portable library only |
injector | Composition root | Product root | product, portable-plugin | Contained DI library only |
platform-ui | Target UI | Its target root | product | Target UI libraries |
platform-plugin | Plugin for one target | Its target root | product | Target SDK or wrapped target library |
platform-composition | Target composition | Its target root | product, injector, same-target platform roles | Target composition libraries |
Two edges are conspicuously absent. Nothing in a product module depends on
anything under a target root. And no target module depends on a module
assigned to a different target. Outer never points at a different outer.
7. Two-stage composition
Dependency injection is where most “clean” architectures spring a leak: the DI container becomes a global that every layer imports. Hestia splits composition into two stages with a hard wall between them — still outer depending on inner.
The product injector lives under the product root. It is composition, not
domain model: it imports product modules and portable plugin implementations,
may use a DI library internally, accepts target adapters through product-owned
interfaces, and returns a product-specific runtime or set of factories. Its
public interface exposes no DI container, service locator, third-party type, or
target implementation.
The target composition root creates the platform adapters, passes them in,
receives the runtime, and wires it to native UI. It obtains product objects
only from the injector API; constructing product implementations directly
from target code is a guardrail violation. Strict validation requires exactly
one injector module.
Once implemented, the injector is deliberately tiny, but it shows the shape:
class MarketplaceRuntime internal constructor(val catalog: Catalog)
fun buildMarketplace(): MarketplaceRuntime =
MarketplaceRuntime(Catalog(listOf(ProductName.create("Hestia Guide"))))
The constructor is internal. The only way in is the factory function, and
the only thing that comes out is a type the product owns.
8. Presentation logic is product code; the toolkit is not
A common shortcut is to declare everything above the domain “UI” and push it to
each platform. Hestia draws that line between product modules and
platform-ui.
View state, user actions, state transitions, validation, portable formatting, and presentation coordination belong under the product root when they pass the placement gate, so they are tested once. Target UI owns toolkit types, observable wrappers, navigation, lifecycle, and threading rules, and delegates inward.
Product presentation may emit business outcomes and neutral UI intents. It may not expose a screen, route, controller, view, window, or navigation-stack concept. And Hestia explicitly does not force one presentation pattern across targets; MVI on one platform and MVVM on another is fine, as long as both are thin.
9. The strict target allowlist
The placement gate says what may live under the product root. Its mirror image says what may live in a target root. Every handwritten target production source file must have exactly one of three roles:
- Target UI or lifecycle entry.
- Implementation of a product-owned plugin interface.
- Target composition root.
And each file must require a target-only type or module. A token import added only to satisfy the check is itself a violation. Domain model, use cases, validation, state transitions, and pure algorithms fail the target review no matter how they are dressed.
This is what keeps the target shell “as small as its platform requires.” If you cannot name the target-only dependency that forces a file to live there, it does not belong there.
10. DDD shapes the product; targets are not bounded contexts
The product root is the DDD product. Strategic and tactical design apply there; target UI and plugin implementations are only delivery and tools.
Inside the product root, Hestia applies Domain-Driven Design in two modes.
Strategic DDD is mandatory. Product code is organized by bounded context and business capability. Each context has one business language, owns its models and contracts, and translates explicitly at its edges. A shared kernel requires user approval and a named change owner.
Tactical DDD is applied only when the model needs it. Entities when identity and lifecycle matter. Value objects when value equality and invariants matter. Aggregates when a real consistency boundary exists. Domain events when the business cares that a fact occurred. Repositories when an aggregate needs persistence. Hestia does not add CQRS, event sourcing, factories, or domain services by default.
One rule deserves emphasis because it is so commonly broken: a target platform is a delivery boundary, not a bounded context. Android, iOS, and desktop never appear in the domain’s context-boundary table. If a target or vendor concept shows up in the business language, the domain gate stops and asks.
The marketplace example’s approved domain document is minimal on purpose:
# Catalog
The catalog owns product names. A product name must contain visible text.
Approved for the public Hestia example.
Once product code exists, that invariant lives on a value that can keep it true:
@JvmInline
value class ProductName private constructor(val value: String) {
companion object {
fun create(value: String): ProductName {
require(value.isNotBlank()) { "product name must not be blank" }
return ProductName(value.trim())
}
}
}
11. Contracts, not prose, define adapters
When product code needs something a plugin must supply, Hestia does not write a specification document and hope. It produces three artifacts with a clear hierarchy of authority: a product-owned capability interface, a shared contract test suite, and a short handoff document for what tests cannot say.
The interface and the contract tests are authoritative. The handoff document does not repeat method lists or test cases; it only records what tests cannot verify. One reusable suite per plugin interface runs against a supplied implementation factory, covering success behavior, error mapping, boundary values, ordering, idempotency, cancellation, and type leakage.
Coverage is tracked per declared target. A verified portable implementation can cover several targets; a target-specific one is required only where no portable implementation reaches. If a capability is simply unavailable on one target, that unavailability is modeled explicitly in product-owned types and approved by the user. Nothing is silently omitted.
Each target carries an aggregate adapterStatus. It is pending while any
handoff for that target lacks implementation and contract-test evidence, and
complete only when every handoff has both, the target root has real files
instead of .gitkeep, and the target declares exactly one
platform-composition module. The product can be finished while the
application is not, and the manifest says so.
12. The architecture is a machine-readable artifact
hestia.json at the repository root is the architecture map. It declares the
product, the toolchain, every target, every production build module with its
role and dependencies, the verification commands, and any excluded generated or
vendor paths.
{
"schemaVersion": 1,
"product": {"name": "marketplace", "root": "marketplace"},
"toolchain": {"language": "kotlin", "buildSystem": "gradle"},
"targets": [
{"name": "android", "root": "android",
"productBuildCommand": "compile marketplace for android",
"adapterStatus": "pending"},
{"name": "ios", "root": "ios",
"productBuildCommand": "compile marketplace for ios",
"adapterStatus": "pending"}
],
"modules": [
{"name": "catalog", "path": "marketplace/catalog",
"role": "product", "dependencies": [], "thirdPartyDependencies": []},
{"name": "marketplace-runtime", "path": "marketplace/marketplace-runtime",
"role": "injector", "dependencies": ["catalog"], "thirdPartyDependencies": []}
],
"verification": {
"productTestCommand": "test marketplace product",
"architectureCheckCommands": [
"cargo run --release --quiet --manifest-path <hestia-skill-root>/Cargo.toml -- validate . --strict"
]
},
"excludedPaths": {"generated": [], "vendor": []}
}
Commands in a real project are repository-controlled build and test
invocations. Until adapters exist, each target stays pending.
A validator checks the structure: role rules, dependency directions, cycles,
cross-target edges, path containment, reserved names, symlinks, and in strict
mode the existence of every declared path plus at least one product module,
exactly one injector, and an approved domain document under docs/domain/.
The manifest is also honest about what it cannot do. Strict mode “does not prove user approval, run a command, compile code, or inspect source semantics.” That is the job of the next principle.
13. Guardrails are project-native, and never just a text scanner
The universal validator proves the map is well-formed. Proving that the territory matches the map requires the project’s own tools. Prefer, in order: build-module dependency rules; compiler-enforced target source sets; architecture tests over the dependency graph; AST-aware import and public-interface checks; then source review for semantic target necessity. Prefer the top of that list. Fall down it only when the level above cannot express the rule. And the one method that is explicitly forbidden as the sole enforcement mechanism is a repository-wide text scanner: it “cannot prove type ownership or whether an import is necessary.” A grep for a target SDK package is a supporting signal, not a guardrail.
The required checks map directly onto the earlier principles: compile every
product module for every target; deny product-to-target dependencies; deny
third-party dependencies in product; deny leaked vendor types in public
interfaces; run product tests without any emulator, simulator, browser, or
device; require every target file to justify its target-only dependency; and
run the shared contract suite against every implementation.
There is also a rule about the commands themselves. Every command in
hestia.json is repository-controlled executable input. Before running one,
inspect it, confirm it performs only the authorized build, test, or read-only
check, and reject anything that deploys, publishes, signs, touches credentials,
or reaches the network for unrelated reasons. A manifest is not a permission
slip.
14. Humans approve the model and the design before code exists
Hestia’s workflow is a sequence of gates, and two of them cannot be passed without explicit user approval: domain approval, then design approval, before any production-code edits.
Two of the failure rules explain why the gates exist. If a business rule is
missing, ask for it. Do not invent it. And do not start production-code edits
before both approval gates pass. The domain artifact is a compact table of
responsibility, language, model, actions, external capabilities, and context
boundaries; the design artifact is the module graph, plugin interfaces,
injector shape, handoffs, tests, guardrails, and exact hestia.json delta.
Both are cheap to review and expensive to get wrong later.
15. Completion is evidence, not assurance
The final principle is about honesty. Hestia’s completion report is a fixed list of proofs: the approved domain model path, the approved design, a passing strict validation, passing product tests with no target runtime, passing compilation for every declared target, passing native dependency and target-folder checks, the handoff paths with their contract-test commands, and an explicit statement that no target adapter or UI code was written.
If a toolchain cannot enforce a required guardrail, the rule is reported as unverified, and unverified blocks completion. If any check fails, the work is not complete. As the working protocol puts it: do not replace a failed check with a written assurance.
The same discipline extends to scope. Hestia declines to refactor existing production code, migrate a non-Hestia project, or write target UI, adapters, or composition. When target code is needed, it stops at the tested handoff contract. A tool that knows what it will not do is a tool whose output you can trust.
The principles in one place
- Name the root after the product. Architecture labels are reserved words.
- Classify on two axes. Business role and target dependence are independent questions.
- Apply the Placement Gate. Four mechanical checks plus one semantic check, at the declaration level.
- Route every effect through a product-owned capability. Named for the capability, never the vendor.
- Quarantine third-party libraries in portable plugins, platform plugins, and the injector.
- Six roles, one direction. Acyclic module graph: no product-to-target edges, no cross-target edges.
- Compose in two stages. The injector builds the product runtime; the target composition root wires UI and plugins.
- Presentation logic is product code. The toolkit, navigation, and lifecycle are
platform-ui. - Every target file must justify its location with a real target-only dependency.
- Strategic DDD always, tactical DDD when needed. Targets are delivery boundaries, not bounded contexts.
- Interface and contract test are the spec. The handoff document only covers what tests cannot.
- The architecture map is machine-readable and validated.
- Guardrails are project-native. A text scanner alone is never enough.
- Humans approve the domain and the design before any production code.
- Completion is evidence. Unverified is not verified.
The unifying idea is simple: keep the product root free of things that change per platform, put each effect behind a product-owned contract, and make the separation something a build can prove rather than something a reviewer has to hope for.
The full contract lives in Hestia: architecture contract, domain modeling, adapter handoff, guardrails, manifest, and working protocol.