Architecture
Kaiord uses Hexagonal Architecture (Ports and Adapters) to keep business logic separate from technical details.
Layer structure
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/ # Logger implementation onlyFormat adapters live in their own packages:
packages/fit/src/ # FIT reader/writer (Garmin FIT SDK)
packages/tcx/src/ # TCX reader/writer (fast-xml-parser)
packages/zwo/src/ # ZWO reader/writer (fast-xml-parser)
packages/garmin/src/ # GCN reader/writer (Garmin Connect JSON)Dependency rules
- domain depends on nothing (pure business logic)
- application depends only on domain and ports
- adapters implement ports and can use external libraries
- CLI/MCP depend on application (not adapters directly)
You can change how files are read or written without touching business logic.
Hexagonal architecture explained
The architecture separates code into layers:
- Domain Layer -- business rules (what makes the app unique)
- Application Layer -- use cases (what the app does)
- Ports -- contracts for external services (what you need from outside)
- Adapters -- implementations of ports (how you connect to outside)
Benefits: testable, flexible, clear separation, maintainable.
Example: reading a FIT file
Port (contract):
// ports/binary-reader.ts
import type { KRD } from "../domain/schemas/krd";
export type BinaryReader = (buffer: Uint8Array) => Promise<KRD>;Adapter (implementation):
// packages/fit/src/adapters/garmin-fitsdk.ts
import type { BinaryReader } from "@kaiord/core";
export const createGarminFitSdkReader =
(logger: Logger): BinaryReader =>
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/from-format.ts
export const fromBinary = async (
buffer: Uint8Array,
reader: BinaryReader,
logger?: Logger
): Promise<KRD> => {
return reader(buffer);
};Use case pattern
Kaiord uses strategy injection -- readers and writers are passed as arguments to generic core functions:
import { fromBinary, toText } from "@kaiord/core";
import { fitReader } from "@kaiord/fit";
import { tcxWriter } from "@kaiord/tcx";
declare const buffer: Uint8Array;
// The core function is format-agnostic
const krd = await fromBinary(buffer, fitReader);
const tcx = await toText(krd, tcxWriter);No dependency injection framework needed. Functions are composed at entry points (CLI, MCP).
Curried use cases
For more complex use cases, Kaiord uses currying for dependency injection. The use case receives adapters (ports) as dependencies and delegates work to them -- validation stays at adapter boundaries, not inside use cases:
// First function receives dependencies (adapters)
// Second function receives operation parameters
export const convertFitToKrd =
(fitReader: FitReader) =>
async (params: { fitBuffer: Uint8Array }): Promise<KRD> => {
// The adapter validates internally at the boundary
return fitReader(params.fitBuffer);
};Composition happens at entry points:
const fitReader = createFitReader(logger);
const convertFitToKrdUseCase = convertFitToKrd(fitReader);
const krd = await convertFitToKrdUseCase({ fitBuffer });Schema-first development
Kaiord uses Zod as the single source of truth:
- Define Zod schemas first, infer types after
- Validate at boundaries (CLI input, adapter output)
- Use cases receive already-validated types
// domain/schemas/sport.ts
export const sportSchema = z.enum([
"cycling",
"running",
"swimming",
"generic",
]);
export type Sport = z.infer<typeof sportSchema>;Schema conventions
- Domain schemas use
snake_case:indoor_cycling,lap_swimming - Adapter schemas use
camelCase:indoorCycling,lapSwimming - Access enum values via
.enum:sportSchema.enum.cycling
Enum schemas
Use z.enum() for enumeration types, never TypeScript enum or constant objects:
export const subSportSchema = z.enum([
"generic",
"indoor_cycling", // snake_case in domain
"lap_swimming",
]);
export type SubSport = z.infer<typeof subSportSchema>;
// Access values at runtime
subSportSchema.enum.indoor_cycling; // "indoor_cycling"Validation at boundaries
Validate at entry points (CLI, adapters), not in use cases:
// Adapter validates its output before returning
export const createFitReader =
(logger: Logger): FitReader =>
async (buffer: Uint8Array): Promise<KRD> => {
const rawData = decoder.read(buffer);
const krd = convertFitMessagesToKRD(rawData.messages);
return krdSchema.parse(krd); // Validate at boundary
};Error handling
Errors follow Clean Architecture principles:
- Define in domain layer (custom Error classes)
- Transform at boundaries (adapters catch external errors, wrap in domain errors)
- Propagate upward (use cases do not catch errors)
- Log at entry points only (CLI, MCP)
Domain Layer → Define custom Error classes
Application Layer → Propagate errors (add context if needed)
Adapters Layer → Catch external errors, transform to domain errors
Entry Points → Catch all errors, log, format responseDomain error classes
All domain errors extend Error with descriptive names:
- FitParsingError -- FIT file parsing failures
- KrdValidationError -- KRD schema validation failures (includes field-level errors)
- ToleranceExceededError -- round-trip tolerance violations (includes per-field deviations)
Error transformation in adapters
Adapters catch external library errors and wrap them:
export const createFitReader =
(logger: Logger): FitReader =>
async (buffer: Uint8Array): Promise<KRD> => {
try {
const { messages } = decoder.read(buffer);
return convertMessagesToKRD(messages);
} catch (error) {
throw new FitParsingError("Failed to parse FIT file", error);
}
};Next steps
- KRD Format -- the canonical data format
- Testing Guide -- testing practices and TDD
- Quick Start -- build something in 5 minutes