Project Structure
This chapter describes how to organize an Umari project workspace. The structure is opinionated but flexible, and umari init scaffolds it for you in either language.
Workspace layout
A project is a workspace with a shared package of events and folds, plus one package/crate per command, projector, and effect.
A Cargo workspace whose root crate is also the shared library:
my-project/
├── Cargo.toml # Workspace root + shared library crate
├── rust-toolchain.toml # Pins the wasm32-wasip2 target
├── src/ # Shared library: events, folds
│ ├── lib.rs
│ ├── events/
│ │ ├── mod.rs
│ │ ├── user.rs
│ │ ├── project.rs
│ │ └── task.rs
│ └── folds/
│ └── mod.rs
├── commands/
│ ├── create-project/ # crate: create-project
│ │ ├── Cargo.toml
│ │ └── src/lib.rs
│ ├── update-project/
│ └── archive-project/
├── projectors/
│ ├── projects/ # crate: projects
│ ├── users/
│ └── tasks/
├── effects/
│ └── register-webhooks/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # Effect + export_effect!
│ ├── commands.rs # Private commands
│ └── events.rs # Effect-private events
└── .gitignore
Root manifest
The root ties the workspace together and pins shared tooling.
The root is both the workspace definition and the shared library crate:
[package]
name = "my-project"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow.workspace = true
schemars.workspace = true
serde.workspace = true
umari.workspace = true
[workspace]
resolver = "2"
members = ["."]
[workspace.dependencies]
my-project = { path = "." }
umari = "0.2"
anyhow = "1.0"
schemars = "1.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.22", features = ["serde", "v4", "v5"] }
validator = { version = "0.20", features = ["derive"] }
wasi-http-client = { version = "0.2", features = ["json"] }
- The root package (
.) is a workspace member: it’s the shared library. my-project = { path = "." }lets module crates depend onmy-project.workspace = true.umariis the SDK crate from crates.io.umari newappends each new module’s path tomembers.
Module manifest
Each module is a minimal package/crate that depends on the shared library and the SDK.
# commands/create-project/Cargo.toml
[package]
name = "create-project"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
my-project.workspace = true # shared library
umari.workspace = true # SDK
anyhow.workspace = true
schemars.workspace = true
serde.workspace = true
crate-type = ["cdylib", "rlib"] is required: cdylib produces the .wasm, rlib enables Rust-level linking for tests. Effects that make HTTP calls add wasi-http-client.workspace = true.
The shared library
Events and folds live in one place that every module imports.
Group events by entity and re-export them:
// src/events/mod.rs
pub mod user;
pub mod project;
pub mod task;
pub use user::*;
pub use project::*;
pub use task::*;
// src/events/user.rs
use umari::prelude::*;
#[derive(Event, DomainIds, Serialize, Deserialize)]
#[event_type("user.registered")]
pub struct UserRegistered {
#[domain_id]
#[crypto_scope]
pub user_id: u64,
pub email: String,
pub name: String,
}
// src/folds/mod.rs
use umari::prelude::*;
use crate::events::*;
#[derive(DomainIds, FromDomainIds)]
pub struct UserExistsFold {
#[domain_id]
pub user_id: u64,
}
impl Fold for UserExistsFold {
type Events = SingleEvent<UserRegistered>;
type State = bool;
fn apply(&self, exists: &mut bool, _event: StoredEvent<UserRegistered>) {
*exists = true;
}
}
src/lib.rs declares the modules: pub mod events; pub mod folds;.
Naming conventions
| Item | Convention | Example |
|---|---|---|
| Event payload | PascalCase, past tense | UserRegistered, ProjectCreated |
| Event type string | object.verb dot notation | "user.registered", "project.created" |
| Command package | kebab-case, imperative | create-project, update-project |
| Command input | Input (Rust struct) / inferred from schema (TS) | pub struct Input / type Input |
| Projector package | kebab-case, plural noun | projects, users |
| Projector | PascalCase, plural | Projects, Users |
| Effect package | kebab-case, verb phrase | register-webhooks |
| Effect | PascalCase | RegisterWebhooks |
| Fold | PascalCase noun + Fold | UserExistsFold, ProjectStateFold |
| Event set | Rust enum Query / TS events: [...] array | enum Query { ... } / events: [UserRegistered] |
Rust domain-ID fields are snake_case (user_id); TypeScript ones are camelCase (userId). The tag name follows the field name; keep them consistent if you mix languages over the same events (see Events).
Dependencies between module types
shared library / package (events, folds)
↑ ↑ ↑
| | |
commands/ projectors/ effects/
(import events, (import events) (import events,
import folds) import folds,
may have own
events + commands)
- Commands import events and folds from the shared library.
- Projectors import events from the shared library.
- Effects import events and folds from the shared library; they may also define their own events and commands locally.
Working with modules
The umari CLI is the intended way to scaffold, build, and deploy: it understands the workspace layout above (in either language), picks up env config, and handles the wasm32-wasip2 / componentize build for you.
umari init
Scaffold a new workspace in the current directory (or a given path), much like cargo init:
umari init # scaffold in the current directory
umari init my-project # scaffold into ./my-project
umari init --lang js # choose the language explicitly
If --lang is omitted, umari init prompts for the language. It generates the root manifest, the shared library/package, a .gitignore, and (for Rust) rust-toolchain.toml, then initializes a git repository if one doesn’t already exist. It’s non-destructive: existing files are left untouched.
umari new
Scaffold a new module, wire it into the workspace, and drop in starter code:
umari new command create-project
umari new projector projects
umari new effect register-webhooks
umari new infers the language from the workspace it’s run in (Cargo vs npm), so you don’t repeat --lang. For a Rust module it appends the crate to the workspace members; for a TypeScript module it wires in the @<project>/shared dependency; run npm install from the root afterward to link it.
umari build
Build every module in the workspace (Rust → wasm32-wasip2, TypeScript → bundled + componentized wasm) in release mode. Pass paths to scope the build:
umari build # build everything
umari build commands/create-project # a single module
umari build commands/ projectors/projects # multiple paths
umari build --debug # debug profile
umari deploy
Build everything (same flags as umari build) and upload + activate each module against your configured server:
umari deploy # build + upload + activate all modules
umari deploy --no-activate # upload but don't activate
umari deploy --bump-patch # auto-bump patch version on conflict
umari deploy commands/create-project
For Rust modules, env vars declared under [package.metadata.umari.env] in the module’s Cargo.toml are sent with the upload. TypeScript modules pass env vars via the lower-level upload commands (--env KEY=VALUE).
Manual / lower-level CLI
To bypass the workspace tooling (one-off uploads, custom flags, or your own scripts), build directly and upload through the typed subcommands:
# Rust
cargo build --target wasm32-wasip2 --release -p create-project
umari commands upload create-project 0.1.0 \
target/wasm32-wasip2/release/create_project.wasm \
--env API_KEY=... --activate
# TypeScript
umari-js build commands/create-project/src/index.ts --out commands/create-project/dist/module.wasm
umari commands upload create-project 0.1.0 \
commands/create-project/dist/module.wasm --activate
For production, always build in release mode: debug builds can be 10× larger and slower.