This chapter covers three commits that happened back to back: the deployment and virtual key management forms, then a shared contracts library, then a frontend data-access library. I want to walk through them in that order because the second and third only make sense as a reaction to a problem the first one created.
The forms come first
DeploymentForm and VirtualKeyForm are built on Angular’s Signal Forms, using form(), required(), and a custom validate() where a built-in validator didn’t fit:
protected readonly deploymentForm = form(this.model, (p) => {
required(p.alias, { message: 'Alias is required' });
required(p.apiKey, {
message: 'API key is required',
when: () => !this.isEditMode(),
});
validate(p.inputPricePerMillionTokens, ({ value }) =>
isNonNegativeNumber(value()) ? undefined : { kind: 'min', message: 'Must be 0 or greater' },
);
});That conditional required on apiKey (only required when creating, not editing) is a small but deliberate UX choice: editing a deployment shouldn’t force you to re-paste the Azure key just to change its alias or pricing. Leave it blank on an edit and the backend keeps whatever’s already encrypted and stored.
VirtualKeyForm has its own interesting bit, model selection is a plain Set<string> of allowed deployment ids rather than anything form-library-managed, because none of the built-in Signal Forms controls map cleanly onto “a checkbox group backed by a set,” and I didn’t want to fight the abstraction for something a plain signal handles fine.
The one-time key reveal flow lives entirely in the page component: the plaintext key returned from create() goes into a transient signal that only exists for the lifetime of a confirmation dialog, never written to any persisted state, so refreshing the page or closing the dialog makes it genuinely unrecoverable, matching the backend’s one-way hash story from the crypto chapter.
The problem I caused for myself
Right after building these forms, I had ModelDeployment and VirtualKey interfaces defined twice: once, informally, in the NestJS DTOs, and again as hand-written frontend models in apps/web. They weren’t identical. A field renamed on one side wouldn’t fail a build on the other side, it would just silently drift until something broke at runtime. That’s exactly the kind of problem I said in the setup chapter that I’d rather feel once than prevent prematurely, and I felt it almost immediately.
So the very next commits extract two Nx libraries. @gatify/contracts (libs/shared/contracts) holds only plain TypeScript interfaces and type aliases, no framework code, no decorators, nothing Angular or NestJS specific:
export type ProviderType = 'AZURE_OPENAI' | 'AZURE_AI_FOUNDRY';
export interface ModelDeployment {
id: string;
alias: string;
provider: ProviderType;
endpoint: string;
deploymentName: string;
apiVersion: string;
isActive: boolean;
isFallback: boolean;
fallbackForId: string | null;
inputPricePerMillionTokens: number | null;
outputPricePerMillionTokens: number | null;
createdAt: string;
updatedAt: string;
}The backend DTOs stay backend-private, since class-validator and Swagger decorators are genuinely behavior, not just shape, but they reference the shared ProviderType alias instead of redeclaring the union. The frontend forms and pages import the same interface from the same package. One definition, two consumers.
@gatify/data-access (libs/frontend/data-access) came right after, pulling the actual HttpClient calls out of page components and into dedicated services:
@Injectable({ providedIn: 'root' })
export class DeploymentsData {
private readonly http = inject(HttpClient);
create(payload: CreateModelDeploymentPayload): Promise<ModelDeployment> {
return firstValueFrom(
this.http.post<ModelDeployment>(`${API_BASE_URL}/providers`, payload),
);
}
}Now the graph looks like web -> data-access -> contracts on the frontend side, and api -> contracts on the backend side, with no edge crossing between the app boundaries directly.
Enforcing it, not just hoping for it
A shared library only stays clean if something stops people (meaning future me) from importing the wrong thing. I replaced a permissive wildcard @nx/enforce-module-boundaries rule with explicit tag constraints:
depConstraints: [
{ sourceTag: 'scope:frontend', onlyDependOnLibsWithTags: ['scope:frontend', 'scope:shared'] },
{ sourceTag: 'scope:backend', onlyDependOnLibsWithTags: ['scope:backend', 'scope:shared'] },
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
],Now if I ever try to import something backend-only into apps/web, or vice versa, lint fails immediately instead of the mistake showing up later as a runtime error or a bundle size surprise. api, api-e2e, web, and contracts each got explicit scope:*/type:* tags matching this, and I re-ran the full lint test build across every project to confirm nothing had silently broken in the extraction.
Then the dashboard, on top of the clean layer
With that foundation in place, the dashboard, usage log page, and per-key detail view got built directly against @gatify/data-access, using Angular’s httpResource for the async data fetching rather than manual subscriptions:
protected readonly summary = httpResource<UsageSummaryPoint[]>(() => this.usageData.summaryUrl(this.keyId));Spend trend and requests-by-model are rendered as plain Tailwind bar visualizations, computed from the resource’s value, no charting library. I made a conscious call there too: pulling in a charting dependency for two simple bar comparisons felt like more surface area than the actual visual complexity justified. If the dashboard ever needs real interactive charts, that’s a good reason to add one later. It didn’t need one yet.
Next: the test chat feature, and a login bug that had nothing to do with authentication and everything to do with how Vite handles workspace library imports.