How your application is shaped

An application built with this framework is one deployable process with one recurring shape:

client ↔ adapter → trait ← implementation

  • A client speaks a protocol: a browser, a REST consumer, a gRPC client, a message producer.
  • An adapter translates that one protocol into calls on your application. The framework ships REST (iet-http-axum) and HTML/HTMX (iet-http-htmx-axum) adapters today; a gRPC or message-consumer adapter would be another crate in the same slot.
  • A trait says what a capability does. It knows no protocol and no storage.
  • An implementation carries the trait out with a real mechanism: a database such as TimescaleDB or PostgreSQL, a remote service call, a published message, a search index such as OpenSearch.
Clientsbrowser (HTMX)REST consumergRPC clientmessage producer
AdaptersHTML adapterJSON adaptergRPC adapterconsumer adapter
TraitNoteswhat it does — no protocol, no storage
ImplementationsTimescaleDBPostgreSQLremote service callmessage publisherOpenSearch
One concrete adapter per client and one concrete implementation per provider; the composition root selects which. Neither side knows the other — both depend only on the trait.

The adapter and the implementation both depend on the trait. Neither knows the other, and neither can replace the other: that is what keeps the capability reusable across applications, clients, and storage engines.

This is application architecture — not framework architecture

The framework's own runtime, contracts, and dependency rules are documented in Architecture. This page is about the app you build with it.

Your app mirrors the framework's integration crates

Look at the framework's crates/integrations/: every crate binds one transport or one storage engine to a contract — iet-auth-basic-axum (login endpoint), iet-auth-basic-timescale (credential persistence), iet-auth-login-htmx-axum (browser sign-in). Your application has exactly that structure:

In the frameworkIn your application
Core contracts (iet-auth, iet-db, iet-cache)Your contract crate: types, rules, ports
Protocol adapters (iet-http-axum, iet-http-htmx-axum)One adapter crate per client
Implementations (iet-db-timescale, iet-cache-memory)One implementation crate per provider
Stack presets (iet-stack-web-axum)Your composition root / server crate
Fake or in-memory providerA test double for the same trait

The contract is the seam. Everything else — database, UI, caller — can be replaced without rewriting the rules between them.

A capability, packaged: contract + implementation + adapter

In application code, we call one such capability a module: code packaged so another application can reuse it. A module usually has three kinds of code, plus a composition step:

PartQuestionMust not know
ContractWhat does the capability do?SQL, HTTP, HTML, a concrete database
ImplementationHow is it carried out?Which UI or route called it
AdapterHow does one caller reach it?How another adapter reaches it
CompositionWhich concrete pieces does this app use?Nothing—it is allowed to know everything

These can be modules in one crate or separate crates. Splitting into crates is a reuse decision, not a moral requirement.

Module here means application feature

This page uses “module” for an application capability such as tasks or billing. It is compile-time code organization, not a dynamically loaded plugin. The framework also has iet_app::Module for supervised process lifecycle; that is a different, narrower use of the word.

A real module in this repository

The browser starter's task feature follows the same shape:

examples/htmx-starter/
crates/task-contract/contractTask, TaskChange, rules, Tasks, TaskRepository
crates/task-impl-timescale/implementationTaskService and SQL repository
crates/task-adapter-htmx/adapterRoutes, pages, and forms
servers/web/compositionSelects concrete implementations and adapters

Dependencies point inward: the adapter depends on the contract, the implementation depends on the contract, and neither the contract nor the implementation depends on Axum or HTML. The composition root depends on all of them.

The contract

The contract owns the domain model, input rules, and the ports other code talks through. This is abbreviated from examples/htmx-starter/crates/task-contract/src/port.rs:

rust
pub trait Tasks: Clone + Send + Sync + 'static {
    fn list(
        &self,
        actor: &Principal,
        query: &str,
        page: u32,
    ) -> impl Future<Output = AppResult<TaskPage>> + Send;

    fn change(
        &self,
        actor: &Principal,
        change: TaskChange<'_>,
    ) -> impl Future<Output = AppResult<()>> + Send;
}

pub trait TaskRepository: Clone + Send + Sync + 'static {
    type Session: Session;
    // list, find, change ...
}

Two details matter for reuse:

  • The actor is an application type (Principal), not an HTTP header or cookie.
  • The repository has an associated Session type. Storage can require a database session without leaking a concrete database into the contract.

The adapter

The HTMX adapter is generic over the ports and supplies routes—not business logic:

rust
// examples/htmx-starter/crates/task-adapter-htmx/src/handlers.rs
pub fn routes<P, T, A, C>() -> Router<AppState<P, T, A, C>>
where
    P: Projects,
    T: Tasks,
    A: Authenticate<Secret<String>> + Clone + Send + Sync + 'static,
    C: LoginCsrf + Clone + Send + Sync + 'static,

The same adapter can be instantiated with a different T in tests or another application. It does not import the Timescale implementation.

The composition root

Only the application entry point chooses concrete types:

rust
// examples/htmx-starter/servers/web/src/main.rs (composition excerpt)
let router = api
    .module("tasks", task_adapter_htmx::routes())?
    .finish(task_adapter_htmx::AppState {
        tasks: task_impl_timescale::TaskService::new(/* ... */),
        // ...
    });

Read it as a sentence: start the app, install the task module's routes, then give those routes the concrete task service. Nothing is discovered by name.

What reuse looks like

Requirement changesReuseReplace
Tests instead of PostgreSQLContract, service rules, adapterRepository with a fake or in-memory implementation
HTML today, JSON tomorrowContract, service rules, storagetask-adapter-htmx with a different adapter
A second application needs tasksThe entire moduleComposition root and selected modules
A different databaseContract and adaptersTimescale implementation

The important dependency rule is one-way knowledge:

Compositionservers/webChooses concrete types once
Adaptertask-adapter-htmxKnows one caller
Contracttask-contractKnows no SQL or HTTP
Implementationtask-impl-timescaleKnows how the work is done
Both edges depend on the contract.The contract depends on neither side.

The composition root is the only place that knows both the adapter and the implementation. That is what lets either side be replaced without rewriting the other.

Runnable proof: the same module in two applications

The checkout contains a small example that makes reuse concrete:

examples/reusable-notes/
notes-module/contractNote, CreateNote, Notes, NoteStore, NotesService
apps/api/adapterAxum adapter: POST /notes, GET /notes/{id}
apps/report/adapterCommand-line app with no web dependency

The module crate depends only on iet-core and Serde. The API app adds Axum and iet-http-axum; the report app adds only Tokio.

Run the command-line application:

sh
cargo run --manifest-path examples/reusable-notes/Cargo.toml -p notes-report --locked
Expected output
note 1: Prepared from a command-line job

It calls the capability directly:

rust
// examples/reusable-notes/apps/report/src/main.rs
let notes = NotesService::new(MemoryNotes::new());
let created = notes.create(CreateNote {
    text: "Prepared from a command-line job".into(),
}).await?;

Start the API in another terminal:

sh
cargo run --manifest-path examples/reusable-notes/Cargo.toml -p notes-api --locked
sh
curl -i http://127.0.0.1:3014/notes \
  -H 'content-type: application/json' \
  -d '{"text":"same module, web caller"}'
Expected response
HTTP/1.1 201 Created
{"id":1,"text":"same module, web caller"}

Its handler is an adapter over the same capability:

rust
// examples/reusable-notes/apps/api/src/main.rs
async fn create(
    State(state): State<AppState>,
    ValidatedJson(input): ValidatedJson<CreateNote>,
) -> Result<Created<Note>, ApiError> {
    Ok(Created(state.notes.create(input).await?))
}

Neither entry point duplicates the validation or storage rule. The API adds HTTP parsing and response mapping; the report app adds none of that.

Why this tends to produce fast code

The core contracts use static dispatch. Repository and service methods return impl Future + Send rather than boxed trait objects, and adapters are generic over the ports. The compiler sees the concrete service type and can inline across the boundary.

The framework also avoids:

  • a runtime service locator or global container;
  • reflective module discovery;
  • a dynamic map lookup to find the service for each request;
  • mandatory boxing at every port.

These are structural advantages, not a guarantee. Database round trips, allocations, serialization, middleware, and deployment dominate real throughput. Measure the whole application under its workload. An individual feature can still introduce its own costs.

Why the code stays readable

There is no annotation that hides the wiring and no registry to inspect at runtime. A constructor call and a type signature show what will run:

rust
let tasks = TaskService::new(repository, cache, /* ... */);
let router = api.module("tasks", task_adapter_htmx::routes())?;

The compiler checks that the adapter's T: Tasks bound is satisfied. If you swap implementations, incompatible wiring fails at build time rather than at the first request.

That is also the trade-off: adding or removing a module changes the Cargo graph and requires a rebuild. This framework chooses explicit, compile-time composition over dynamic plugins.

Start small, split when reuse is real

You do not need four crates for a first application. Start with one crate and keep the boundaries clear:

my-app/src/
task.rscontractTypes, rules, and ports
service.rsimplementationApplication behavior
memory.rsimplementationStorage mechanics
routes.rsadapterHTTP translation

Promote a boundary to its own crate when another application, another adapter, or another implementation actually needs it. A module becomes reusable when its dependencies stop pointing outward—not merely when its files are separated.

What modules do not do automatically

  • Ownership is not inferred. A Principal parameter does not filter rows; the repository must include the owner in its predicates.
  • Transactions are not implicit. Use CallScope/TransactionScope when several database operations must succeed together.
  • Authentication is not installed. An adapter must obtain a trusted principal before calling a protected operation.
  • Performance is not guaranteed. Static wiring removes some overhead; it does not optimize your queries.

Next: choose the capabilities your application needs, read the framework architecture, or follow the notes application to see the same boundaries in a small runnable example.

IET Framework documentation — built with the IET Framework.