services-layer
$
npx mdskill add EpicenterHQ/epicenter/services-layerImplements service layer patterns with error handling and domain-specific logic
- Solves the problem of organizing business logic into pure, testable services
- Uses Result types, defineErrors, and namespace exports for structured error handling
- Applies service layer architecture to isolate logic from UI and dependencies
- Delivers consistent service interfaces for consumption by query or RPC layers
SKILL.md
.github/skills/services-layerView on GitHub ↗
---
name: services-layer
description: 'Service layer patterns: defineErrors, namespace exports, Result types. Use when: "create a service", "service layer", creating services, defining domain-specific errors.'
metadata:
author: epicenter
version: '2.0'
---
# Services Layer Patterns
This skill documents how to implement services in the Whispering architecture. Services are pure, isolated business logic with no UI dependencies that return `Result<T, E>` types for error handling.
> **Related Skills**: See `error-handling` for trySync/tryAsync patterns. See `define-errors` for error variant factories. See `query-layer` for consuming services with TanStack Query.
## When to Apply This Skill
Use this pattern when you need to:
- Create a new service with domain-specific error handling
- Add error types with structured context (like HTTP status codes)
- Understand how services are organized and exported
- Implement platform-specific service variants (desktop vs web)
## Core Architecture
Services follow a three-layer architecture: **Service** → **Query** → **UI**
```
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ UI │ --> │ RPC/Query │ --> │ Services │
│ Components │ │ Layer │ │ (Pure) │
└─────────────┘ └─────────────┘ └──────────────┘
```
**Services are:**
- **Pure**: Accept explicit parameters, no hidden dependencies
- **Isolated**: No knowledge of UI state, settings, or reactive stores
- **Testable**: Easy to unit test with mock parameters
- **Consistent**: All return `Result<T, E>` types for uniform error handling
## Creating Errors with defineErrors
Every service defines domain-specific errors using `defineErrors` from wellcrafted. Errors are grouped into a namespace object where each key becomes a variant.
```typescript
import { defineErrors, type InferError, type InferErrors, extractErrorMessage } from 'wellcrafted/error';
import { Err, Ok, type Result, tryAsync, trySync } from 'wellcrafted/result';
// Namespace-style error definition : name describes the domain
const CompletionError = defineErrors({
ConnectionFailed: ({ cause }: { cause: unknown }) => ({
message: `Connection failed: ${extractErrorMessage(cause)}`,
cause,
}),
EmptyResponse: ({ providerLabel }: { providerLabel: string }) => ({
message: `${providerLabel} API returned an empty response`,
providerLabel,
}),
MissingParam: ({ param }: { param: string }) => ({
message: `${param} is required`,
param,
}),
});
// Type derivation : shadow the const with a type of the same name
type CompletionError = InferErrors<typeof CompletionError>;
type ConnectionFailedError = InferError<typeof CompletionError.ConnectionFailed>;
// Call sites : each variant returns Err<...> directly
return CompletionError.ConnectionFailed({ cause: error });
return CompletionError.EmptyResponse({ providerLabel: 'OpenAI' });
return CompletionError.MissingParam({ param: 'apiKey' });
```
### How defineErrors Works
`defineErrors({ ... })` takes an object of factory functions and returns a namespace object. Each key becomes a variant:
- **`name` is auto-stamped** from the key (e.g., key `NotFound` → `error.name === 'NotFound'`)
- **The factory function IS the message generator** : it returns `{ message, ...fields }`
- **Each variant returns `Err<...>` directly** : no separate `FooErr` constructor needed
- **Types use `InferError` / `InferErrors`** : not `ReturnType`
```typescript
// No-input variant (static message)
const RecorderError = defineErrors({
Busy: () => ({
message: 'A recording is already in progress',
}),
});
// Usage : no arguments needed
return RecorderError.Busy();
// Variant with derived fields : constructor extracts from raw input
const HttpError = defineErrors({
Response: ({ response, body }: { response: { status: number }; body: unknown }) => ({
message: `HTTP ${response.status}: ${extractErrorMessage(body)}`,
status: response.status,
body,
}),
});
// Usage : pass raw objects, constructor derives fields
return HttpError.Response({ response, body: await response.json() });
// error.message → "HTTP 401: Unauthorized"
// error.status → 401 (derived from response, flat on the object)
// error.name → "Response"
```
### Error Type Examples from the Codebase
```typescript
// Static message, no input needed
const RecorderError = defineErrors({
Busy: () => ({
message: 'A recording is already in progress',
}),
});
RecorderError.Busy()
// Multiple related errors in a single namespace
const HttpError = defineErrors({
Connection: ({ cause }: { cause: unknown }) => ({
message: `Failed to connect to the server: ${extractErrorMessage(cause)}`,
cause,
}),
Response: ({ response, body }: { response: { status: number }; body: unknown }) => ({
message: `HTTP ${response.status}: ${extractErrorMessage(body)}`,
status: response.status,
body,
}),
Parse: ({ cause }: { cause: unknown }) => ({
message: `Failed to parse response body: ${extractErrorMessage(cause)}`,
cause,
}),
});
// Union type for the whole namespace
type HttpError = InferErrors<typeof HttpError>;
// Individual variant type
type ConnectionError = InferError<typeof HttpError.Connection>;
```
## Key Rules
1. **Services never import settings** - Pass configuration as parameters
2. **Services never import UI code** - No toasts, no notifications, no WhisperingError
3. **Always return Result types** - Never throw errors
4. **Use trySync/tryAsync** - See the error-handling skill for details
5. **Export factory + Live instance** - Factory for testing, Live for production
6. **Use defineErrors namespaces** - Group related errors under a single namespace
7. **Derive types with InferError/InferErrors** - Not `ReturnType`
8. **Variant names describe the failure mode** - Never use generic names like `Service`, `Error`, or `Failed`. The namespace provides domain context (`RecorderError`), so the variant must say *what went wrong* (`AlreadyRecording`, `InitFailed`, `StreamAcquisition`). `RecorderError.Service` is meaningless : `RecorderError.AlreadyRecording` tells you exactly what happened.
9. **Split discriminated union inputs** - Each variant gets its own name and shape. If the constructor branches on its inputs (if/switch/ternary) to decide the message, each branch should be its own variant
10. **Transform cause in the constructor, not the call site** - Accept `cause: unknown` and call `extractErrorMessage(cause)` inside the factory's message template. Call sites pass the raw error: `{ cause: error }`. This centralizes message extraction where the message is composed and keeps call sites clean.
## References
Load these on demand based on what you're working on:
- If working with **error variant anti-patterns** (discriminated union inputs, branching constructors), read [references/error-anti-patterns.md](references/error-anti-patterns.md)
- If working with **service implementation details** (factory patterns, recorder service examples), read [references/service-implementation-pattern.md](references/service-implementation-pattern.md)
- If working with **service organization and platform variants** (namespace exports, desktop vs web services), read [references/service-organization-platforms.md](references/service-organization-platforms.md)
- If working with **error message authoring** (user-friendly/actionable message design), read [references/error-message-best-practices.md](references/error-message-best-practices.md)
- See `apps/whispering/src/lib/services/README.md` for architecture details
- See the `query-layer` skill for how services are consumed
- See the `error-handling` skill for trySync/tryAsync patterns
More from EpicenterHQ/epicenter
- agent-goalWrite `/goal` prompts for long-running agent work in Codex or Claude Code. Use for slash goal, agent goal, durable objective, autonomous coding run.
- approachability-auditReview code as a new TypeScript developer. Use when code feels indirect, clever, hard to follow, or needs a pass on abstractions, names, first-read clarity.
- arktypeArktype: runtime validation, discriminated unions with .merge()/.or(), spread keys. Use when mentioning arktype, type(), union types, command/event schemas.
- attach-primitiveContract and invariants for `attach*` composition primitives in `packages/workspace` (side-effectful building blocks like attachIndexedDb, attachSqlite, attachBroadcastChannel, attachEncryption, attachTable, openCollaboration), and when to use `create*` (pure construction) instead. Use when writing or reviewing an `attach*` or `create*` function, naming a new workspace primitive, composing inside a workspace builder, or deciding whether a primitive registers listeners at call time.
- authEpicenter auth packages: `@epicenter/auth`, `@epicenter/auth-svelte`, OAuth sessions, identity state, auth-owned fetch/WebSocket, and workspace lifecycle binding. Use when editing Epicenter auth clients, session state, hosted sign-in, or auth/workspace integration.
- autumnAutumn billing in Epicenter: `autumn.config.ts`, `autumn-js` credit checks, `atmn` CLI, plan gates, and metered AI usage. Use when changing billing, pricing, credits, plan access, refunds, or usage events.
- better-auth-best-practicesBetter Auth server/client setup: `auth.ts`, generated schema, DB adapters, sessions, cookies, env vars, and plugins. Use when mentioning Better Auth, betterauth, auth handlers, OAuth, email/password, or session configuration.
- better-auth-security-best-practicesBetter Auth security hardening: rate limits, secrets, CSRF, trusted origins, cookies, sessions, OAuth tokens, and audit logging. Use when reviewing auth security, brute-force protection, token handling, or deployment safety.
- change-proposalPresent proposed code changes visually before implementing. Use when: "show me options", "compare approaches", "what should we do", or when changes need before/after comparison.
- claude-code-consultUse this skill when the user asks to consult Claude, ask Claude Code, get another model's take, run a taste check, find cleaner options, or prepare a Claude prompt. Create a bounded second-opinion prompt or run a read-only Claude Code consult, then verify Claude's claims against local files.