3. Relate your slices
An author exists to have written things. So our second feature is adding a book to an author’s catalog — and it’s the first time two slices in our app relate to each other. A book belongs to an author; we’ll model that with a plain foreign key — the author’s id on the book — and read a catalog back with a filtered query.
Here’s the shape:
The book slice
Section titled “The book slice”Continue from chapter 2, using its project and one database branch. Create BookId.cs, BookTitle.cs, AddBook.cs, and Book.cs under src/Books/, each with namespace Library.Books; above the declarations shown. Add global using Library.Books; to GlobalUsings.cs. Existing global imports supply the Arc, provider, and author types.
On the JVM, the same four declarations go in a books package beside the authors one, each file importing what it uses — there is no global-import step. Keep the package inside the application’s component-scan root so Spring finds the repository and Arc’s KSP processor sees the artifacts.
- Strong types for the book. Same discipline as before — a record deriving from
ConceptAs<T>on C#, a data class implementingConceptAs<T>on the JVM:
public record BookId(Guid Value) : ConceptAs<Guid>(Value){ public static readonly BookId NotSet = new(Guid.Empty);
public static BookId New() => new(Guid.NewGuid());}
public record BookTitle(string Value) : ConceptAs<string>(Value){ public static readonly BookTitle NotSet = new(string.Empty);
public static implicit operator BookTitle(string value) => new(value);}// A record component named value satisfies ConceptAs without any extra code.public record BookId(UUID value) implements ConceptAs<UUID> { public static BookId newId() { return new BookId(UUID.randomUUID()); }}
public record BookTitle(String value) implements ConceptAs<String> {}data class BookId(private val rawValue: UUID) : ConceptAs<UUID> { override fun value(): UUID = rawValue
companion object { fun new(): BookId = BookId(UUID.randomUUID()) }}
data class BookTitle(private val rawValue: String) : ConceptAs<String> { override fun value(): String = rawValue}import { ConceptAs, Guid } from '@cratis/fundamentals';
export class BookId extends ConceptAs<Guid> { static readonly valueType = Guid;
static create(): BookId { return new BookId(Guid.create()); }}
export class BookTitle extends ConceptAs<string> { static readonly valueType = String;}The C# sentinels and the implicit conversion are conveniences, not requirements; the JVM has no implicit-conversion operator, so a Kotlin caller writes BookTitle("...") where C# can pass a bare string.
- The command writes a book, tagged with its author.
AddBookcarries theAuthorIdit belongs to — and writes aBookcarrying that id:
[Command]public record AddBook(AuthorId AuthorId, BookId BookId, BookTitle Title){ public Task Handle(IMongoCollection<Book> books) => books.InsertOneAsync(new Book(BookId, AuthorId, Title));}@Command@AllowAnonymouspublic record AddBook(AuthorId authorId, BookId bookId, BookTitle title) { public void handle(@FromServices BookRepository books) { books.save(new Book(bookId, authorId, title)); }}@Command@AllowAnonymousdata class AddBook(val authorId: AuthorId, val bookId: BookId, val title: BookTitle) { suspend fun handle(@FromServices books: BookRepository) { books.save(Book(bookId, authorId, title)) }}import { field } from '@cratis/fundamentals';import { command, inject } from '@cratis/arc.core';
@command()export class AddBook { @field(AuthorId) authorId!: AuthorId; @field(BookId) bookId!: BookId; @field(BookTitle) title!: BookTitle;
@inject(BookRepository) handle(books: BookRepository): Promise<void> { return books.save({ id: this.bookId, authorId: this.authorId, title: this.title }); }}On the relational branch, .NET takes the context and saves through it. The JVM and TypeScript handlers still use a repository; its implementation supplies the relational write:
[Command]public record AddBook(AuthorId AuthorId, BookId BookId, BookTitle Title){ public async Task Handle(LibraryDbContext db) { db.Books.Add(new Book(BookId, AuthorId, Title)); await db.SaveChangesAsync(); }}@Command@AllowAnonymouspublic record AddBook(AuthorId authorId, BookId bookId, BookTitle title) { public void handle(@FromServices BookRepository books) { books.save(new Book(bookId, authorId, title)); }}@Command@AllowAnonymousdata class AddBook(val authorId: AuthorId, val bookId: BookId, val title: BookTitle) { suspend fun handle(@FromServices books: BookRepository) { books.save(Book(bookId, authorId, title)) }}import { field } from '@cratis/fundamentals';import { command, inject } from '@cratis/arc.core';
@command()export class AddBook { @field(AuthorId) authorId!: AuthorId; @field(BookId) bookId!: BookId; @field(BookTitle) title!: BookTitle;
@inject(BookRepository) handle(books: BookRepository): Promise<void> { return books.save({ id: this.bookId, authorId: this.authorId, title: this.title }); }}On the JVM, the command still uses BookRepository: its document-store implementation can use MongoRepository<Book, …>, while its relational implementation delegates to JpaRepository<BookRow, UUID> and converts between Book and the mapped row.
On TypeScript, create Features/Books/BookId.ts and BookTitle.ts from the concepts above, Features/Books/Book.ts from the query below, and Features/Books/AddBook.ts from the command above. Import each type the excerpts use from its feature file, with .js on relative backend imports. Put this runtime token in Features/Books/BookRepository.ts; both the command and query import it (not as a type-only import):
import type { ObservableSource } from '@cratis/arc.core';// Features/Books/BookRepository.ts — import AuthorId and Book from their feature files.
export abstract class BookRepository { abstract save(book: Book): Promise<void>; abstract observeForAuthor(authorId: AuthorId): ObservableSource<Book[]>;}The MongoDB implementation belongs beside it in Features/Books/MongoBookRepository.ts. It encodes books through the collection’s codec before writing; the filter uses the codec’s stored field name and the encoded author id so it matches MongoDB’s UUID representation, not a JavaScript Guid object:
import type { ObservableSource } from '@cratis/arc.core';import type { MongoCollection } from '@cratis/arc.mongodb';import type { Document, Filter } from 'mongodb';// Features/Books/MongoBookRepository.ts — import AuthorId, Book, and BookRepository from their feature files.
export class MongoBookRepository extends BookRepository { constructor(private readonly collection: MongoCollection<Book>) { super(); }
async save(book: Book): Promise<void> { const document = this.collection.codec.serialize(book); await this.collection.native.replaceOne({ _id: document._id } as Filter<Document>, document, { upsert: true }); }
observeForAuthor(authorId: AuthorId): ObservableSource<Book[]> { const field = this.collection.codec.fieldName('authorId'); // BookId and AuthorId both use Guid; the key-field codec also encodes this author id. const storedId = this.collection.codec.id(authorId); return this.collection.observe({ [field]: storedId }); }}In main.ts, import Book, BookRepository, and MongoBookRepository, then add Book to the existing readModels list and register the repository before builder.discover(...). Keep the chapter-1 tenant resolver, author repository, and chapter-2 unique index startup:
// main.ts — merge these lines into the existing builder setup, before discover().import { mongoCollection } from '@cratis/arc.mongodb';// Import Book, BookRepository, and MongoBookRepository from ./Features/Books/.
builder.withMongoDB({ server: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017', database: 'Library', readModels: [Author, Book]});builder.services.addScoped(BookRepository, async scope => new MongoBookRepository(await scope.resolve(mongoCollection(Book))));Regenerate with npm run generate after adding the files. The MongoDB setup explains collection tokens and model registration in more detail.
Read a catalog back, filtered
Section titled “Read a catalog back, filtered”The read model is just the book document — it carries the AuthorId it belongs to. The query takes the author’s id (Arc binds it by matching the parameter name) and returns only that author’s books, live:
[ReadModel]public record Book(BookId Id, AuthorId AuthorId, BookTitle Title){ public static ISubject<IEnumerable<Book>> BooksForAuthor(AuthorId authorId, IMongoCollection<Book> books) => books.Observe(b => b.AuthorId == authorId);}@ReadModel@AllowAnonymouspublic record Book(BookId id, AuthorId authorId, BookTitle title) { public static Flow.Publisher<List<Book>> booksForAuthor( AuthorId authorId, @FromServices MongoObservableQuery queries ) { return MongoObservations.observe( queries, Book.class, Criteria.where("authorId").is(authorId.value())); }}@ReadModel@AllowAnonymousdata class Book(val id: BookId, val authorId: AuthorId, val title: BookTitle) { companion object { @JvmStatic fun booksForAuthor( authorId: AuthorId, @FromServices queries: MongoObservableQuery ): Flow<List<Book>> = queries.observe<Book>(Criteria.where("authorId").`is`(authorId.value())) }}import { field } from '@cratis/fundamentals';import { argument, query, readModel, service, type ObservableSource } from '@cratis/arc.core';
@readModel()export class Book { @field(BookId) id!: BookId; @field(AuthorId) authorId!: AuthorId; @field(BookTitle) title!: BookTitle;
@query({ observable: true }, argument('authorId', AuthorId), service(BookRepository)) static booksForAuthor(authorId: AuthorId, books: BookRepository): ObservableSource<Book[]> { return books.observeForAuthor(authorId); }}The filter is the only difference worth noting: C# expresses it as a typed predicate over the read model, the JVM passes Spring Data Criteria naming the stored field, and TypeScript’s MongoDB repository observes a filter on the collection’s mapped authorId field. Each returns a stream — ISubject<…>, Flow<List<…>>, or ObservableSource<Book[]> — which is what makes the query live. On the TypeScript relational branch, the Drizzle repository uses eq(books.authorId, authorId.value) and announces changes after committed writes.
On the relational branch, read from the configured relational store instead:
[ReadModel]public record Book(BookId Id, AuthorId AuthorId, BookTitle Title){ public static ISubject<IEnumerable<Book>> BooksForAuthor(AuthorId authorId, LibraryDbContext db) => db.Books.Observe(b => b.AuthorId == authorId);}@Entity@Table(name = "books")class BookRow { @Id UUID id; @Column(name = "author_id", nullable = false) UUID authorId; @Column(name = "title", nullable = false) String title; protected BookRow() {} BookRow(UUID id, UUID authorId, String title) { this.id = id; this.authorId = authorId; this.title = title; }}
@ReadModel@AllowAnonymouspublic record Book(BookId id, AuthorId authorId, BookTitle title) { public static Flow.Publisher<List<Book>> booksForAuthor( AuthorId authorId, @FromServices JpaObservableQuery queries ) { return queries.observePublisher(Book.class, em -> em.createQuery("select b from BookRow b where b.authorId = :authorId", BookRow.class) .setParameter("authorId", authorId.value()).getResultList().stream() .map(row -> new Book(new BookId(row.id), new AuthorId(row.authorId), new BookTitle(row.title))) .toList()); }}@Entity@Table(name = "books")class BookRow( @field:Id var id: UUID = UUID(0, 0), @field:Column(name = "author_id", nullable = false) var authorId: UUID = UUID(0, 0), @field:Column(name = "title", nullable = false) var title: String = "")
@ReadModel@AllowAnonymousdata class Book(val id: BookId, val authorId: AuthorId, val title: BookTitle) { companion object { @JvmStatic fun booksForAuthor(authorId: AuthorId, @FromServices queries: JpaObservableQuery): Flow<List<Book>> = queries.observe(Book::class.java, JpaSnapshotQuery { em -> em.createQuery("select b from BookRow b where b.authorId = :authorId", BookRow::class.java) .setParameter("authorId", authorId.value()).resultList .map { Book(BookId(it.id), AuthorId(it.authorId), BookTitle(it.title)) } }) }}import { eq } from 'drizzle-orm';import { sqliteTable, text } from 'drizzle-orm/sqlite-core';import type { SQLJsDatabase } from 'drizzle-orm/sql-js';import { field } from '@cratis/fundamentals';import { argument, query, readModel, service, type ObservableSource } from '@cratis/arc.core';import { DrizzleDialect, guidCodec, sqliteColumn, type DrizzleHandle, type DrizzleReadModels } from '@cratis/arc.drizzle';
export const books = sqliteTable('books', { id: sqliteColumn(guidCodec(DrizzleDialect.SQLite))('id').primaryKey(), authorId: sqliteColumn(guidCodec(DrizzleDialect.SQLite))('author_id').notNull(), title: text('title').notNull()});
export class DrizzleBookRepository extends BookRepository { constructor(private readonly writer: DrizzleHandle<SQLJsDatabase>, private readonly records: DrizzleReadModels<Book>) { super(); }
async save(book: Book): Promise<void> { this.writer.native.insert(books).values({ id: book.id.value, authorId: book.authorId.value, title: book.title.value }).run(); this.writer.notifyChanged(books); }
observeForAuthor(authorId: AuthorId): ObservableSource<Book[]> { return this.records.observe(eq(books.authorId, authorId.value)); }}
@readModel()export class Book { @field(BookId) id!: BookId; @field(AuthorId) authorId!: AuthorId; @field(BookTitle) title!: BookTitle;
@query({ observable: true }, argument('authorId', AuthorId), service(BookRepository)) static booksForAuthor(authorId: AuthorId, repository: BookRepository): ObservableSource<Book[]> { return repository.observeForAuthor(authorId); }}// Register Book against books in withDrizzle({ readModels: [{ type: Book, table: books }],// observation: DrizzleObservation.InProcess }), and register the scoped BookRepository.For EF Core, also add public DbSet<Book> Books => Set<Book>(); to LibraryDbContext. Apply the schema change before running: recreate only the disposable tutorial SQLite database as explained in setup, or use a migration for retained data. Re-register an author after recreating it. MongoDB creates its book collection on the first write. On the JVM, the snapshot query projects mapped JPA rows into the Book read model; configure the repository to persist those rows and publish changes for Book after commit. JPA observation is in-process only. TypeScript’s Drizzle observation also requires explicit table registration and change announcements after writes. The tab shows the SQLite/sql.js adapter; observe SQL tables shows the withDrizzle registration, schema setup, and scoped repository pattern. SQL writes must be committed before notifyChanged when a transaction is owned outside Arc.
Before adding React, regenerate the proxies — dotnet build -c Debug on C#, ./gradlew generateArcProxies on the JVM, or npm run generate on TypeScript (using the application’s generator script). On C# and JVM, verify src/Books/AddBook.ts and src/Books/Book.ts exist; the latter includes BooksForAuthor in source-file output mode. With the TypeScript backend’s co-located --use-proxy-file-suffix setup, import AllAuthors in src/Catalog.tsx from ../Features/Authors/AllAuthors.proxy, BooksForAuthor from ../Features/Books/BooksForAuthor.proxy, and AddBook in src/AddBook.tsx from ../Features/Books/AddBook.proxy. The src/Books/Book and src/Books/AddBook imports in the React examples below are for the .NET/JVM source-file grouping.
Showing the catalog
Section titled “Showing the catalog”BooksForAuthor takes an author id, so it’s a query per author. The list of authors comes from chapter 1’s AllAuthors; each row renders its own live catalog:
import { Guid } from '@cratis/fundamentals';import { useDialog } from '@cratis/arc.react/dialogs';import { AddBookDialog } from './AddBook';import { AllAuthors } from './Authors/Author';import { BooksForAuthor } from './Books/Book'; // generated proxies
const AuthorBooks = ({ authorId }: { authorId: Guid }) => { const [books] = BooksForAuthor.use({ authorId }); // live, scoped to this author return <ul>{books.data.map(b => <li key={String(b.id)}>{b.title}</li>)}</ul>;};
export const Catalog = () => { const [authors] = AllAuthors.use(); const [AddBookDialogWrapper, showAddBook] = useDialog(AddBookDialog); return ( <> <ul> {authors.data.map(a => ( <li key={String(a.id)}> {a.name} <button onClick={() => void showAddBook({ authorId: a.id })}>Add book</button> <AuthorBooks authorId={a.id} /> </li> ))} </ul> <AddBookDialogWrapper /> </> );};Adding a book is a CommandDialog<AddBook>, exactly like RegisterAuthor — the only difference is that it carries the authorId of the author you’re adding to. Pass that in as an initial value:
import { useState } from 'react';import { Guid } from '@cratis/fundamentals';import { CommandDialog } from '@cratis/components/CommandDialog';import { InputTextField } from '@cratis/components/CommandForm';import { AddBook } from './Books/AddBook';
export const AddBookDialog = ({ authorId }: { authorId: Guid }) => { const [bookId] = useState(() => Guid.create()); return ( <CommandDialog<AddBook> command={AddBook} title="Add book" okLabel="Add" initialValues={{ authorId, bookId }}> <InputTextField<AddBook> value={i => i.title} title="Title" /> </CommandDialog> );};Mount the catalog
Section titled “Mount the catalog”Replace src/App.tsx from chapter 1 with this composition: import Catalog instead of Authors and render <Catalog /> instead of <Authors />. Keep the Add author action and both providers:
import { Arc } from '@cratis/arc.react';import { useDialog } from '@cratis/arc.react/dialogs';import { CratisComponentsProvider } from '@cratis/components';import { AddAuthor } from './AddAuthor';import { Catalog } from './Catalog';
function Library() { const [AddAuthorDialog, showAddAuthor] = useDialog(AddAuthor); return ( <> <button onClick={() => void showAddAuthor()}>Add author</button> <Catalog /> <AddAuthorDialog /> </> );}
export const App = () => ( <Arc> <CratisComponentsProvider> <Library /> </CratisComponentsProvider> </Arc>);Catalog owns one dialog wrapper, outside the author loop. Each Add book button passes that row’s Guid to showAddBook. The wrapper mounts AddBookDialog only while open and unmounts it on close, so its bookId state is fresh for the next operation.
Check the relationship
Section titled “Check the relationship”Click Add book beside one author, add a book, then open the dialog again to add a second book. Verify that their ids differ and only that author’s list grows. Another author’s list should remain unchanged. Run npx tsc -p tsconfig.web.json --noEmit (or npx tsc6 -p tsconfig.web.json --noEmit with the TypeScript backend) and npx vite build from the frontend project root: BooksForAuthor.use({ authorId }) requires a parameter object, not a positional id or a string conversion; use the dist-web output from chapter 1 so the TypeScript backend’s dist/main.js survives the Vite build.
What you built
Section titled “What you built”- A second slice —
AddBook— that tags each book with the author it belongs to. - A relationship as a foreign key + a filtered query:
BooksForAuthorreads exactly one author’s catalog, live, with no join. - A React screen that composes the two: a list of authors, each rendering its own live catalog.
Two features, related, both live. We keep saying “it stays live” — next we’ll make that concrete and watch a screen update itself the instant the data changes. Let’s make it live →