Skip to content

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:

writes

live

AddBook (keyed by author)

Books

BooksForAuthor (filtered by AuthorId)

React: author → books

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.

  1. Strong types for the book. Same discipline as before — a record deriving from ConceptAs<T> on C#, a data class implementing ConceptAs<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);
}

View C# snippet source on GitHub

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.

  1. The command writes a book, tagged with its author. AddBook carries the AuthorId it belongs to — and writes a Book carrying 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));
}

View C# snippet source on GitHub

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();
}
}

View C# snippet source on GitHub

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[]>;
}

View TypeScript (preview) snippet source on GitHub

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 });
}
}

View TypeScript (preview) snippet source on GitHub

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))));

View TypeScript (preview) snippet source on GitHub

Regenerate with npm run generate after adding the files. The MongoDB setup explains collection tokens and model registration in more detail.

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);
}

View C# snippet source on GitHub

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);
}

View C# snippet source on GitHub

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.

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:

src/Catalog.tsx
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:

src/AddBook.tsx
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>
);
};

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:

src/App.tsx
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.

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.

  • A second slice — AddBook — that tags each book with the author it belongs to.
  • A relationship as a foreign key + a filtered query: BooksForAuthor reads 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 →