The Principles of Hestia Architecture

September 20, 2026 · 17 min read
  • architecture
  • kotlin
  • agent-skills

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.

Product root — use cases and domain model inside plugin contracts, with target UI and plugins outside

From the center outward:

  1. 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.
  2. Domain services — only when logic spans entities and must not live in use-case coordination. Hestia does not invent these by default.
  3. 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.
  4. 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.
Hestia roleWhere it lives
productProduct root — domain model, use cases, presentation, plugin contracts
portable-pluginProduct root — plugin implementation that compiles for every target
injectorProduct root — composition root (not domain model)
platform-uiTarget root — target UI
platform-pluginTarget root — plugin implementation for one target
platform-compositionTarget 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.

  1. What job does it do? Domain model, use-case coordination, product presentation, plugin contract, plugin implementation, or composition.
  2. 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:

JobOne targetEvery declared target
Domain model, use case, presentationForbidden. Split it.Catalog, checkout view state
Plugin implementation, injector, UI, compositionCompose screen, RoomSessionStoreJSON 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:

  1. It compiles without a target SDK or target-only library.
  2. It imports only product modules and the language standard library or compiler-shipped runtime.
  3. Its public interface exposes only product-owned types.
  4. 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:

  1. Name the capability, never the vendor.

    SessionStore.loadSession()       good
    FirebasePlugin.getDocument()     bad
    
  2. Use product-owned request, result, error, and event types. The implementation translates every external model and error at its boundary. No HttpResponse, no Cursor, no Flow<T> from a third-party stream library, no DI container type in the contract surface.

  3. 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:

WhereRoleCondition
Under the product rootportable-pluginThe implementation compiles for every declared target
Under one target rootplatform-pluginThe implementation needs that target
Under the product rootinjectorOnly 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.

RoleJobLocationMay depend onThird-party dependencies
productDomain model, use cases, presentation, contractsProduct rootproductNone
portable-pluginPlugin for every declared targetProduct rootproductWrapped portable library only
injectorComposition rootProduct rootproduct, portable-pluginContained DI library only
platform-uiTarget UIIts target rootproductTarget UI libraries
platform-pluginPlugin for one targetIts target rootproductTarget SDK or wrapped target library
platform-compositionTarget compositionIts target rootproduct, injector, same-target platform rolesTarget 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:

  1. Target UI or lifecycle entry.
  2. Implementation of a product-owned plugin interface.
  3. 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

  1. Name the root after the product. Architecture labels are reserved words.
  2. Classify on two axes. Business role and target dependence are independent questions.
  3. Apply the Placement Gate. Four mechanical checks plus one semantic check, at the declaration level.
  4. Route every effect through a product-owned capability. Named for the capability, never the vendor.
  5. Quarantine third-party libraries in portable plugins, platform plugins, and the injector.
  6. Six roles, one direction. Acyclic module graph: no product-to-target edges, no cross-target edges.
  7. Compose in two stages. The injector builds the product runtime; the target composition root wires UI and plugins.
  8. Presentation logic is product code. The toolkit, navigation, and lifecycle are platform-ui.
  9. Every target file must justify its location with a real target-only dependency.
  10. Strategic DDD always, tactical DDD when needed. Targets are delivery boundaries, not bounded contexts.
  11. Interface and contract test are the spec. The handoff document only covers what tests cannot.
  12. The architecture map is machine-readable and validated.
  13. Guardrails are project-native. A text scanner alone is never enough.
  14. Humans approve the domain and the design before any production code.
  15. 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.