This document explains how Kaiord is built and organized. It covers the core library architecture and the frontend SPA editor.
- Core Library Architecture
- Hexagonal Architecture
- Use Case Pattern
- Schema-First Development
- Error Handling
- SPA Editor Architecture
Kaiord uses Hexagonal Architecture (also called Ports and Adapters) to keep business logic separate from technical details.
packages/core/src/
├── domain/ # Business rules and data types
│ ├── schemas/ # Zod schemas for KRD format
│ ├── validation/ # Business validators
│ └── types/ # Error types
├── application/ # Use cases (business operations)
├── ports/ # Contracts for external services
├── adapters/ # Implementations for external services
│ ├── fit/ # FIT file format adapter
│ ├── tcx/ # TCX file format adapter
│ └── zwift/ # Zwift file format adapter
└── cli/ # Command-line interface
- domain depends on nothing (pure business logic)
- application depends only on domain and ports
- adapters implement ports and can use external libraries
- cli depends on application (not adapters directly)
This means you can change how files are read/written without touching business logic.
Hexagonal Architecture separates your code into layers:
- Domain Layer - Your business rules (what makes your app unique)
- Application Layer - Your use cases (what your app does)
- Ports - Contracts for external services (what you need from outside)
- Adapters - Implementations of ports (how you connect to outside)
- Testable: Test business logic without external dependencies
- Flexible: Change file formats without changing business logic
- Clear: Each layer has a specific purpose
- Maintainable: Easy to understand and modify
Port (Contract):
// ports/fit-reader.ts
import type { KRD } from "../domain/schemas/krd";
export type FitReader = (buffer: Uint8Array) => Promise<KRD>;Adapter (Implementation):
// adapters/fit/garmin-fitsdk.ts
import type { FitReader } from "../../ports/fit-reader";
import { Decoder, Stream } from "@garmin/fitsdk";
export const createGarminFitSdkReader =
(logger: Logger): FitReader =>
async (buffer: Uint8Array): Promise<KRD> => {
const stream = Stream.fromByteArray(Array.from(buffer));
const decoder = new Decoder(stream);
const { messages } = decoder.read();
return convertMessagesToKRD(messages);
};Use Case:
// application/use-cases/convert-fit-to-krd.ts
export const convertFitToKrd =
(fitReader: FitReader, validator: SchemaValidator) =>
async (params: { fitBuffer: Uint8Array }): Promise<KRD> => {
const krd = await fitReader(params.fitBuffer);
const errors = validator.validate(krd);
if (errors.length > 0) {
throw new KrdValidationError("Validation failed", errors);
}
return krd;
};Domain schemas define the canonical KRD format using snake_case for multi-word values:
domain/schemas/
├── sport.ts # sportSchema + Sport type
├── sub-sport.ts # subSportSchema + SubSport type (snake_case)
├── duration.ts # durationSchema + Duration type
├── target.ts # targetSchema + Target type
└── krd.ts # krdSchema + KRD type
Example:
// domain/schemas/sub-sport.ts
export const subSportSchema = z.enum([
"generic",
"indoor_cycling", // snake_case
"lap_swimming",
]);Adapter schemas represent external formats using camelCase to match external SDKs:
adapters/fit/schemas/
├── fit-sport.ts # fitSportSchema + FitSport type
├── fit-sub-sport.ts # fitSubSportSchema + FitSubSport type (camelCase)
└── fit-duration.ts # fitDurationTypeSchema + FitDurationType type
Example:
// adapters/fit/schemas/fit-sub-sport.ts
export const fitSubSportSchema = z.enum([
"generic",
"indoorCycling", // camelCase
"lapSwimming",
]);- Domain schemas define the canonical KRD format (single source of truth)
- Adapter schemas define external format-specific concepts
- Clear boundaries prevent domain contamination
- Bidirectional mapping happens in mappers
- Domain never imports adapters - maintains architecture integrity
Use cases are business operations that your application performs. Kaiord uses a functional pattern with currying for dependency injection.
// Input parameters type
type UseCaseParams = {
// Parameters for this operation
};
// Exported type (automatically inferred)
export type UseCaseName = ReturnType<typeof useCaseName>;
// Main function with currying for dependency injection
export const useCaseName =
(dependency1: Dependency1, dependency2: Dependency2) =>
async (params: UseCaseParams): Promise<ReturnType> => {
// Business logic here
};Currying for Dependency Injection:
- First function receives dependencies (services, ports)
- Second function receives operation parameters
- No dependency injection framework needed
Layer Separation:
- Use cases depend only on ports (interfaces)
- They don't know about concrete implementations
- Respects Clean Architecture rules
Type Safety:
ReturnType<typeof useCaseName>infers the type automatically- Easy to test with typed mocks
- No duplicate type definitions
// application/use-cases/convert-fit-to-krd.ts
import type { KRD } from "../../domain/schemas/krd";
import type { FitReader } from "../../ports/fit-reader";
import type { SchemaValidator } from "../../domain/validation/schema-validator";
type ConvertFitToKrdParams = {
fitBuffer: Uint8Array;
};
export type ConvertFitToKrd = ReturnType<typeof convertFitToKrd>;
export const convertFitToKrd =
(fitReader: FitReader, validator: SchemaValidator) =>
async (params: ConvertFitToKrdParams): Promise<KRD> => {
const krd = await fitReader(params.fitBuffer);
const errors = validator.validate(krd);
if (errors.length > 0) {
throw new KrdValidationError("Validation failed", errors);
}
return krd;
};import { describe, expect, it, vi } from "vitest";
import type { FitReader } from "../../ports/fit-reader";
import { convertFitToKrd } from "./convert-fit-to-krd";
describe("convertFitToKrd", () => {
it("should convert FIT buffer to KRD", async () => {
// Arrange
const fitBuffer = new Uint8Array([1, 2, 3, 4]);
const expectedKrd = buildKRD.build();
const mockFitReader = vi.fn<FitReader>().mockResolvedValue(expectedKrd);
const mockValidator = { validate: vi.fn().mockReturnValue([]) };
// Act
const result = await convertFitToKrd(
mockFitReader,
mockValidator
)({
fitBuffer,
});
// Assert
expect(result).toStrictEqual(expectedKrd);
expect(mockFitReader).toHaveBeenCalledWith(fitBuffer);
});
});// CLI or API handlers
import { convertFitToKrd } from "../application/use-cases/convert-fit-to-krd";
import { createFitReader } from "../adapters/fit/garmin-fitsdk";
import { createSchemaValidator } from "../domain/validation/schema-validator";
// Create concrete implementations
const fitReader = createFitReader(logger);
const validator = createSchemaValidator();
// Create use case with dependencies
const convertFitToKrdUseCase = convertFitToKrd(fitReader, validator);
// Execute
const krd = await convertFitToKrdUseCase({ fitBuffer });- Testability: Easy to mock dependencies
- Type Safety: TypeScript infers types automatically
- Composition: Composable and reusable
- Clean Architecture: Respects dependency inversion
- No Frameworks: No decorators or DI containers needed
- Immutability: Pure functions without shared state
Kaiord uses Zod as the single source of truth for schemas and TypeScript types.
- Schema → Type: Define Zod schemas first, infer types after
- Validation at boundaries: Validate at entry points (CLI, adapters)
- Reusable domain schemas: Shared schemas in
domain/schemas/ - No internal validation: Use cases receive already-validated types
// ✅ Correct: camelCase + "Schema" suffix
export const krdMetadataSchema = z.object({ ... });
export const workoutStepSchema = z.object({ ... });
export const sportSchema = z.enum(["cycling", "running", "swimming"]);
// ✅ Infer types with z.infer
export type KRDMetadata = z.infer<typeof krdMetadataSchema>;
export type WorkoutStep = z.infer<typeof workoutStepSchema>;
export type Sport = z.infer<typeof sportSchema>;
// ❌ Incorrect
export type KRDMetadata = { ... }; // Don't define types manually
const KRDMetadata = z.object({ ... }); // Wrong case
export const sportEnum = z.enum([...]); // Wrong suffix// domain/schemas/duration.ts
import { z } from "zod";
// 1. Define Zod schema
export const durationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("time"),
seconds: z.number().positive(),
}),
z.object({
type: z.literal("distance"),
meters: z.number().positive(),
}),
z.object({
type: z.literal("open"),
}),
]);
// 2. Infer TypeScript type
export type Duration = z.infer<typeof durationSchema>;Use z.enum() for enumeration types:
// ✅ Correct: Enum schema with runtime validation
export const sportSchema = z.enum([
"cycling",
"running",
"swimming",
"generic",
]);
export type Sport = z.infer<typeof sportSchema>;
// Access enum values via .enum property
sportSchema.enum.cycling; // "cycling"
sportSchema.enum.running; // "running"
// Validate at runtime
const result = sportSchema.safeParse("cycling");
if (result.success) {
console.log(result.data); // "cycling"
}
// ❌ Incorrect: Constant object (deprecated)
export const SPORT_TYPE = {
CYCLING: "cycling",
RUNNING: "running",
} as const;CLI Commands:
// packages/cli/src/commands/convert.ts
import { z } from "zod";
const cliArgsSchema = z.object({
input: z.string(),
output: z.string(),
format: z.enum(["fit", "tcx", "zwo"]),
});
export const convertCommand = async (args: unknown) => {
// Validate at boundary
const validated = cliArgsSchema.parse(args);
// Pass validated types to use case
return await convertFileUseCase(validated);
};Adapters:
// adapters/fit/garmin-fitsdk.ts
import { Decoder, Stream } from "@garmin/fitsdk";
import { krdSchema } from "../../domain/schemas/krd";
export const createFitReader =
(logger: Logger): FitReader =>
async (buffer: Uint8Array): Promise<KRD> => {
const stream = Stream.fromByteArray(Array.from(buffer));
const decoder = new Decoder(stream);
const rawData = decoder.read();
// Convert to KRD
const krd = convertFitMessagesToKRD(rawData.messages);
// Validate result before returning
return krdSchema.parse(krd);
};✅ Do:
- Define Zod schemas first, types after
- Use
z.enum()for enumeration types - Use
z.discriminatedUnionfor variants - Validate at boundaries (CLI, adapters)
- Access enum values via
.enumproperty - Use
.safeParse()for validation - Separate domain and adapter schemas
❌ Don't:
- Don't define TypeScript types manually
- Don't use constant objects for enums
- Don't use TypeScript
enumkeyword - Don't validate in use cases
- Don't duplicate schemas
- Don't use
z.any()without justification - Don't maintain JSON Schema manually
Kaiord uses custom Error classes that follow Clean Architecture principles.
- Define errors in domain layer - Custom Error classes with domain entities
- Transform at boundaries - Convert external errors to domain errors in adapters
- Propagate upward - Let errors bubble up to entry points
- Log at entry points - Structured logging only at application boundaries
- Never silence errors - Always handle or propagate, never ignore
Domain Layer
↓ Define custom Error classes
Application Layer
↓ Propagate domain errors (add context if needed)
Adapters Layer
↓ Catch external errors, transform to domain errors
Entry Points (CLI)
↓ Catch all errors, log, format response
All domain errors extend Error:
export class FitParsingError extends Error {
public override readonly name = "FitParsingError";
constructor(
message: string,
public readonly cause?: unknown
) {
super(message);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, FitParsingError);
}
}
}FitParsingError - FIT file parsing failures:
throw new FitParsingError("Failed to parse FIT file", originalError);KrdValidationError - KRD schema validation failures:
throw new KrdValidationError("KRD validation failed", [
{ field: "version", message: "Required field missing" },
]);ToleranceExceededError - Round-trip tolerance violations:
throw new ToleranceExceededError("Round-trip conversion exceeded tolerance", [
{ field: "power", expected: 250, actual: 252, deviation: 2, tolerance: 1 },
]);Adapters catch external library errors and transform to domain errors:
// adapters/fit/garmin-fitsdk.ts
import { Decoder, Stream } from "@garmin/fitsdk";
export const createFitReader =
(logger: Logger): FitReader =>
async (buffer: Uint8Array): Promise<KRD> => {
try {
const stream = Stream.fromByteArray(Array.from(buffer));
const decoder = new Decoder(stream);
const { messages } = decoder.read();
return convertMessagesToKRD(messages);
} catch (error) {
// Transform external error to domain error
throw new FitParsingError("Failed to parse FIT file", error);
}
};Use cases generally do not catch errors - they propagate:
// application/use-cases/convert-fit-to-krd.ts
export const convertFitToKrd =
(fitReader: FitReader, validator: SchemaValidator) =>
async (params: { fitBuffer: Uint8Array }): Promise<KRD> => {
// No try-catch - let errors propagate
const krd = await fitReader(params.fitBuffer);
const errors = validator.validate(krd);
if (errors.length > 0) {
throw new KrdValidationError("KRD validation failed", errors);
}
return krd;
};CLI commands catch all errors, log them, and format user-friendly messages:
// packages/cli/src/commands/convert.ts
import { readFileSync } from "fs";
export const convertCommand = async (args: ConvertArgs) => {
try {
// Read input file
const buffer = readFileSync(args.input);
const result = await convertFitToKrd({ fitBuffer: buffer });
console.log("✓ Conversion successful");
return result;
} catch (error) {
// Log with structure
logger.error("Conversion failed", {
command: "convert",
input: args.input,
error: serializeError(error),
});
// User-friendly messages
if (error instanceof FitParsingError) {
console.error(`Error: Failed to parse FIT file`);
console.error(`Details: ${error.message}`);
process.exit(1);
}
if (error instanceof KrdValidationError) {
console.error(`Error: Invalid KRD format`);
console.error(`Validation errors:`);
for (const err of error.errors) {
console.error(` - ${err.field}: ${err.message}`);
}
process.exit(1);
}
// Unknown error
console.error(`Error: An unexpected error occurred`);
console.error(error);
process.exit(1);
}
};✅ Do:
- Extend Error class for all domain errors
- Use descriptive names ending in "Error"
- Add context properties for debugging
- Preserve stack traces
- Transform at boundaries (adapters)
- Log at entry points only
- Use instanceof for error type checking
❌ Don't:
- Don't use plain objects for errors
- Don't catch without re-throwing in use cases
- Don't log multiple times for same error
- Don't silence errors with empty catch blocks
- Don't use string error codes instead of classes
- Don't lose stack traces when wrapping errors
The Workout SPA Editor is a mobile-first React application for creating and editing KRD workout files.
- React 19 - UI framework
- TypeScript 5 - Type safety
- Vite 7 - Build tool
- Zustand 5 - State management
- Zod 3 - Schema validation
- Radix UI - Accessible components
- Tailwind CSS 4 - Styling
1. Mobile-First Design:
- Touch-friendly interactions (44x44px minimum)
- Responsive layouts
- Optimized for small screens
2. Atomic Design:
Components organized by complexity:
Atoms → Molecules → Organisms → Templates → Pages
3. Separation of Concerns:
- Components - Presentation logic
- Store - State management
- Utils - Data transformation
- Types - Type definitions
4. Type Safety:
- TypeScript strict mode
- No
anytypes - Zod schemas for validation
- Type inference from schemas
5. Accessibility First:
- WCAG 2.1 AA compliance
- Semantic HTML
- ARIA attributes
- Keyboard navigation
- Screen reader support
Atoms (Basic Building Blocks):
Button- UI button with variantsInput- Form input with validationBadge- Status indicatorIcon- Icon wrapper
Molecules (Simple Combinations):
StepCard- Workout step displayDurationPicker- Duration inputTargetPicker- Target inputFileUpload- File upload with validation
Organisms (Complex Components):
WorkoutList- List of workout stepsStepEditor- Step editing formWorkoutStats- Statistics display
Templates (Page Layouts):
MainLayout- Main application layout
Pages (Route Components):
WelcomeSection- File upload pageWorkoutSection- Main editor page
The application uses Zustand for global state:
interface WorkoutStore {
// State
currentWorkout: KRD | null;
workoutHistory: KRD[];
historyIndex: number;
selectedStepId: string | null;
isEditing: boolean;
// Actions
loadWorkout: (krd: KRD) => void;
updateWorkout: (krd: KRD) => void;
selectStep: (id: string | null) => void;
setEditing: (editing: boolean) => void;
undo: () => void;
redo: () => void;
}Features:
- Undo/Redo history (max 50 states)
- Optimized selectors with memoization
- Pure action functions
- Type-safe with TypeScript
Unidirectional data flow:
User Action
↓
Component Event Handler
↓
Store Action
↓
State Update
↓
Component Re-render
All validation uses Zod schemas from @kaiord/core:
Validation Points:
- File Upload - Validate file format and schema
- User Input - Real-time validation during editing
- Before Save - Final validation before file save
Error Messages:
User-friendly and actionable:
// Good: "Duration must be a positive number"
// Good: "Power zone must be between 1 and 7"- Code Splitting: Automatic by Vite
- Memoization:
useMemoanduseCallback - Optimized Re-renders:
React.memoand selective subscriptions - Build Optimizations: Minification, tree shaking, code splitting
WCAG 2.1 AA Compliance:
- Semantic HTML
- Color contrast ratios (4.5:1 minimum)
- Keyboard navigation
- Focus indicators
- Screen reader support
- ARIA attributes
Keyboard Shortcuts:
- Tab - Navigate between elements
- Enter/Space - Activate buttons
- Escape - Close dialogs
- Ctrl+Z - Undo
- Ctrl+Y - Redo
- Ctrl+S - Save
Test Pyramid:
E2E Tests (Playwright)
/ \
/ Integration Tests \
/ \
/ Unit Tests \
/__________________________ \
- Unit Tests: Component rendering, interactions, state updates
- Integration Tests: Component interactions, form submissions
- E2E Tests: Complete user flows, mobile responsiveness