Skip to content
Documentation GitHub
Development Guides

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) on PageDetail that you can study directly in crates/domain/src/page_detail.rs and crates/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_minutes field. They require the domain change from Step 1 to compile, since reading_time_minutes does not exist on PageDetail in the actual codebase — the real equivalent field is word_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 relevant INSERT/SELECT statements 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 PageRepository trait in application/src/page/services.rs, then implement it on SqlitePageRepository.

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:

  1. Extract inputs from the Tauri runtime
  2. Validate and sanitize those inputs (security boundary)
  3. Delegate to an application use case
  4. 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:

Terminal window
pnpm generate:types

Specta 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:

  1. Starting from the domain forces you to define the concept correctly before implementation details get involved
  2. Application layer tests verify the logic before any UI exists — no browser, no rendering, no mock servers
  3. 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:

LayerWhereWhat it proves
Domaincrates/domain/src/page_detail.rsStruct construction, field invariants
Applicationcrates/application/src/page/get_detail.rsReading time computation logic, permission enforcement
Infrastructuretests/core/tests/page_detail.rsEnd-to-end through real SQLite
Frontendapps/desktop/tests/specs/context-panel.spec.tsComponent 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 panel

Key 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 how TestWorkspace is 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?