Tutorial: Your First Feature
Learn how the codebase is structured by adding a new computed field to pages. This tutorial walks through every layer — domain, application, infrastructure, Tauri command, and React frontend — and explains the why at each step, not just the what.
This tutorial puts the patterns from the Development Guide into practice.
Note: This tutorial is illustrative. The changes shown are for learning purposes. Do not commit them — the codebase already has a similar field (
word_count) onPageDetailthat you can study directly incrates/domain/src/page_detail.rsandcrates/application/src/page/get_detail.rs.
What You’ll Build
A reading_time_minutes field on PageDetail that estimates how long it takes to read a page, based on a 200
words-per-minute reading speed.
This is a deliberately simple feature. Its simplicity lets you focus on the architecture, not the logic.
What You’ll Learn
- Why the codebase is split into domain, application, infrastructure, and framework layers, and how to add to each one
- The file-per-use-case pattern and why it keeps the application layer navigable
- How to write tests at each layer with the right scope
- How Specta automatically carries Rust types into TypeScript so the frontend never drifts out of sync with the backend
Prerequisites
- Development environment set up (see Getting Started)
- Basic familiarity with Rust and TypeScript
- Familiarity with the layer organization in README.md
Step 1: Domain Layer
File: crates/domain/src/page_detail.rs
The change
Add reading_time_minutes to PageDetail:
#[derive(Debug, Clone, Serialize, Deserialize, Type)]pub struct PageDetail { pub slug: String, pub title: String, pub page_type: PageType, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, pub word_count: u32, pub backlink_count: u32, pub outgoing_link_count: u32, pub backlinks: Vec<BacklinkInfo>, pub outgoing_links: Vec<OutgoingLinkInfo>, pub type_assignments: Vec<TypeAssignment>,
/// Estimated reading time in minutes, based on 200 wpm. pub reading_time_minutes: u32,}Why domain first?
The domain layer is the innermost ring of the architecture. It contains pure business entities with no external dependencies — no database, no HTTP, no filesystem. Starting here forces you to define what the concept is before deciding how it is stored or computed.
PageDetail is a composite type that combines data from multiple sources (page metadata, reference index) into a single
structure. It lives in the domain crate because it represents a meaningful business concept: “everything the UI needs to
render the context panel for a page.” Adding reading_time_minutes here makes it part of that contract.
The #[derive(specta::Type)] on PageDetail is what connects this Rust type to TypeScript. When you run
pnpm generate:types, Specta reads the derive and emits a TypeScript interface with the same fields. The field name
stays snake_case — Specta does not convert to camelCase.
At this layer, there are no tests to write for a new field — the domain layer tests focus on invariants (like “every page must have at least one block”), not on struct field existence.
Step 2: Application Layer
File: crates/application/src/page/get_detail.rs
The change
GetPageDetailUseCase already computes word_count from block content. Add reading time alongside it:
pub fn execute(&self, guard: &PermissionGuard, slug: &str) -> PageResult<PageDetail> { guard.require(Capability::PagesRead) .map_err(|e| PageRepositoryError::InvalidOperation(e.to_string()))?;
tracing::debug!(slug, "get_page_detail"); let page = self.repository.get_by_slug(slug)?;
// Compute word count across all block content let word_count: u32 = page.blocks .iter() .map(|b| b.content.split_whitespace().count() as u32) .sum();
// 200 words per minute, rounded up, minimum 1 minute let reading_time_minutes = ((word_count as f32 / 200.0).ceil() as u32).max(1);
// ... backlinks, outgoing links (unchanged)
Ok(PageDetail { slug: page.slug(), title: page.title, page_type: page.page_type, created_at: page.created_at, updated_at: page.updated_at, word_count, reading_time_minutes, // ← new backlink_count: backlinks.len() as u32, outgoing_link_count: outgoing_links.len() as u32, backlinks, outgoing_links, type_assignments: page.type_assignments, })}Why the application layer?
The application layer is the home of use cases — the things your system does, expressed as plain Rust logic. Use
cases depend only on the domain layer and on abstract repository traits defined in services.rs. They never touch
SQLite, HTTP, or the filesystem directly.
This is where reading_time_minutes belongs because it is a derived computation, not raw stored data. Consider the
alternatives:
- Putting it in the domain entity (
Page) would require either storing it (wasteful — it is derivable) or computing it at entity construction time (leaking application logic into the domain). - Putting it in the Tauri command would place business logic in the framework layer, making it untestable without spinning up a Tauri runtime.
The application layer is visible, testable, and independent of how data moves in and out.
The file-per-use-case pattern
Each use case has its own file:
crates/application/src/page/├── mod.rs # Re-exports├── services.rs # PageRepository trait + error types├── get.rs # GetPageUseCase├── get_detail.rs # GetPageDetailUseCase ← you are here├── create.rs # CreatePageUseCase├── update.rs # UpdatePageUseCase└── ...This pattern keeps files small and navigable. To understand reading time, you open get_detail.rs. There is no “god
file” accumulating all page logic. Each file is independently testable and its scope is clear from its name.
Application layer tests
Application layer tests use mock repositories from crates/application/src/test_helpers.rs. They never touch SQLite and
run in milliseconds:
Note: These tests illustrate the pattern for testing a use case that returns a
reading_time_minutesfield. They require the domain change from Step 1 to compile, sincereading_time_minutesdoes not exist onPageDetailin the actual codebase — the real equivalent field isword_count, which you can study directly.
#[cfg(test)]mod tests { use super::*; use crate::permission::test_helpers::owner_guard; use crate::test_helpers::{MockPageRepository, MockReferenceRepository}; use domain::Page;
#[test] fn test_reading_time_short_page_rounds_up_to_one_minute() { // 180 words at 200 wpm would be 0.9 minutes — rounds up to 1 let mut page = Page::new("Short Page"); page.update_block_content(1, "word ".repeat(180).trim());
let repo = MockPageRepository::with_pages(vec![page]); let ref_repo = MockReferenceRepository::new(); let use_case = GetPageDetailUseCase::new(repo, ref_repo);
let detail = use_case.execute(&owner_guard(), "short-page").unwrap(); assert_eq!(detail.reading_time_minutes, 1); }
#[test] fn test_reading_time_exactly_two_minutes() { // 400 words ÷ 200 wpm = exactly 2 minutes let mut page = Page::new("Medium Page"); page.update_block_content(1, "word ".repeat(400).trim());
let repo = MockPageRepository::with_pages(vec![page]); let ref_repo = MockReferenceRepository::new(); let use_case = GetPageDetailUseCase::new(repo, ref_repo);
let detail = use_case.execute(&owner_guard(), "medium-page").unwrap(); assert_eq!(detail.reading_time_minutes, 2); }
#[test] fn test_reading_time_empty_page_returns_one() { // An empty page (0 words) returns 1, not 0 let page = Page::new("Empty Page");
let repo = MockPageRepository::with_pages(vec![page]); let ref_repo = MockReferenceRepository::new(); let use_case = GetPageDetailUseCase::new(repo, ref_repo);
let detail = use_case.execute(&owner_guard(), "empty-page").unwrap(); assert_eq!(detail.reading_time_minutes, 1); }}Test naming follows the convention: test_[what]_[condition]_[expected].
MockPageRepository::with_pages(vec![...]) creates a repository pre-populated with specific pages.
MockReferenceRepository::new() gives an empty reference index. Both are implemented in
crates/application/src/test_helpers.rs — you do not need to write your own.
Run these tests with: cargo test -p application
Step 3: Infrastructure Layer
File: crates/infrastructure/sqlite/src/workspace/page_repository.rs
The change
For reading_time_minutes, there is nothing to change in the infrastructure layer.
Why not?
reading_time_minutes is a derived field computed from block content that the page repository already loads. The
SQLite repository stores block content as LoroDoc BLOBs (the CRDT source of truth) with materialized text alongside it.
That text is already loaded into page.blocks[*].content when get_by_slug returns. The use case derives reading time
from that data — no new SQL query, no new column.
You would change the infrastructure layer if the new field required:
- A new database column: Add a migration in
crates/infrastructure/sqlite/src/migrations/(e.g.,V014__add_reading_time.sql), then add the column to the relevantINSERT/SELECTstatements in the repository. SQLite silently defaults missing columns to NULL with no error signal, so audit all statements for the affected table. - A new SQL query: Add a method to the
PageRepositorytrait inapplication/src/page/services.rs, then implement it onSqlitePageRepository.
For purely derived fields, infrastructure is a no-op. The separation is working correctly — you added logic without touching storage.
Step 4: Tauri Command
File: apps/desktop/src-tauri/src/commands/page.rs
The change
None. The existing get_page_detail command already returns PageDetail by value:
#[tauri::command]#[specta::specta]pub fn get_page_detail( state: State<AppState>, slug: String, op_id: Option<String>,) -> Result<PageDetail, CommandError> { let _span = command_span_with_id("get_page_detail", op_id).entered(); validate_slug_input(&slug, "slug")?;
let _workspace = state.require_workspace()?; let guard = state.resolve_owner_guard()?;
let use_case = GetPageDetailUseCase::new(state.page_repo(), state.reference_repo()); use_case .execute(&guard, &slug) .map_err(|e| sanitize_error(e, "get_page_detail"))}Because PageDetail now contains reading_time_minutes, the command returns it automatically. There is no mapping
layer to update.
Why are Tauri commands thin?
Tauri commands are framework adapters. Their job is:
- Extract inputs from the Tauri runtime
- Validate and sanitize those inputs (security boundary)
- Delegate to an application use case
- Return the result, or a sanitized error
All business logic lives in use cases. The command knows nothing about word counting or reading time. This is the same principle as the application layer: the framework should not know how features work, only which use case handles each command.
How Specta generates TypeScript
The #[specta::specta] attribute registers the command with Specta. When you run:
pnpm generate:typesSpecta compiles the Tauri app as a library, walks all registered commands and their return types, and emits TypeScript
into packages/contracts/generated/bindings.ts. The generated type for PageDetail will now include
reading_time_minutes: number.
This is why you never hand-write TypeScript types for backend data. The Rust types are the source of truth. Running
pnpm generate:types after any Rust struct change is the only required step.
Step 5: React Frontend
File: somewhere in apps/desktop/src-react/ — the context panel component
The change
After pnpm generate:types, the TypeScript PageDetail interface in packages/contracts/generated/bindings.ts
includes the new field:
export type PageDetail = { slug: string; title: string; page_type: PageType; created_at: string; updated_at: string; word_count: number; reading_time_minutes: number; // ← new backlink_count: number; outgoing_link_count: number; backlinks: BacklinkInfo[]; outgoing_links: OutgoingLinkInfo[]; type_assignments: TypeAssignment[];};To display it, find where word_count is already rendered and add the new field alongside:
<span className="page-stat"> {detail.word_count.toLocaleString()} words {" · "} {detail.reading_time_minutes} min read</span>Why the frontend is last
In the vertical slice methodology, the frontend is written last. This order is intentional:
- Starting from the domain forces you to define the concept correctly before implementation details get involved
- Application layer tests verify the logic before any UI exists — no browser, no rendering, no mock servers
- The Tauri command provides a tested, stable API surface for the frontend to consume
If you start from the frontend, you risk designing the data model around what is convenient to display rather than what is correct to model. Starting from the domain inverts that pressure.
The frontend is also the layer where “it works” has the weakest signal. A component that renders undefined does not
fail loudly. Starting from domain and application layers means that by the time you reach the frontend, you have already
verified the logic in fast, deterministic unit tests.
Step 6: Tests at Each Layer
Here is a summary of where tests live and what each layer proves:
| Layer | Where | What it proves |
|---|---|---|
| Domain | crates/domain/src/page_detail.rs | Struct construction, field invariants |
| Application | crates/application/src/page/get_detail.rs | Reading time computation logic, permission enforcement |
| Infrastructure | tests/core/tests/page_detail.rs | End-to-end through real SQLite |
| Frontend | apps/desktop/tests/specs/context-panel.spec.ts | Component renders the field from mock data |
Integration test
The integration test verifies a full round-trip through real SQLite:
// In tests/core/tests/page_detail.rs
mod common;
use application::{CreatePageRequest, CreatePageUseCase, GetPageDetailUseCase};use common::{owner_guard, setup_workspace};use infrastructure_sqlite::{SqlitePageRepository, SqliteReferenceRepository};
#[test]fn test_reading_time_survives_storage_round_trip() { let ws = setup_workspace("reading-time-test"); let guard = owner_guard();
// Create a page with a known word count let create_uc = CreatePageUseCase::new(ws.page_repo.clone()); create_uc.execute(&guard, CreatePageRequest { title: "Test Page".to_string(), parent_slug: None, content: Some("word ".repeat(400).trim().to_string()), }).unwrap();
// Construct the reference repo from the shared db connection pool let ref_repo = SqliteReferenceRepository::with_cached_db(ws.db.clone());
// Load via GetPageDetailUseCase and verify let detail_uc = GetPageDetailUseCase::new(ws.page_repo.clone(), ref_repo); let detail = detail_uc.execute(&guard, "test-page").unwrap();
assert_eq!(detail.word_count, 400); assert_eq!(detail.reading_time_minutes, 2);}setup_workspace("name") creates a fresh SQLite database in a temporary directory and wires all repositories. It lives
in tests/core/tests/common/mod.rs. The reference repository is not a field on TestWorkspace — construct it directly
from ws.db using SqliteReferenceRepository::with_cached_db(ws.db.clone()).
Run integration tests with: cargo test -p core-tests
Recap
You have traced a feature through the full vertical slice:
1. Domain crates/domain/src/page_detail.rs Add reading_time_minutes field
2. Application crates/application/src/page/get_detail.rs Compute reading_time_minutes from word_count Unit tests with mock repositories
3. Infrastructure (no changes — derived field, no new storage) Would change here for: new columns, new SQL
4. Tauri command apps/desktop/src-tauri/src/commands/page.rs No changes — returns PageDetail by value
5. Type generation pnpm generate:types Emits reading_time_minutes: number in bindings.ts
6. React frontend apps/desktop/src-react/ Render detail.reading_time_minutes in context panelKey principles illustrated
Clean Architecture enforces dependency direction at compile time. The domain crate has no use application or
use infrastructure. Trying to import across this boundary fails the build. You cannot accidentally tangle the layers.
The application layer is the testable core. Unit tests here require no database, no filesystem, and no Tauri runtime. They run in milliseconds and provide the best signal-to-noise ratio.
Infrastructure is a detail. For this feature, you did not touch SQLite at all. The block content was already loaded. When you do need a new database column, you add a migration and update the repository — but you never change the use case to accommodate database-specific concerns.
Specta is the API contract. Running pnpm generate:types is the only way to update TypeScript types. Manually
editing bindings.ts is explicitly forbidden — the file header says so. This prevents frontend/backend drift
permanently.
Where to go next
- Read an existing use case:
crates/application/src/page/create.rs— notice how it creates the initial block and enforces the “min 1 block” domain invariant - Read an existing integration test:
tests/core/tests/page_lifecycle.rs— see howTestWorkspaceis used for realistic end-to-end tests - Read the domain rules:
apps/codex/src/content/docs/architecture/domain-rules.mdx— the invariants that every use case must preserve - Read the development guide: Development Guide — deeper coverage of the vertical slice methodology and commit discipline
- Browse guides:
docs/guides/— task-oriented guides for specific common operations
Was this page helpful?
Thanks for your feedback!