EpicenterHQ

services-layer

3,976
268
# Install this skill:
npx skills add EpicenterHQ/epicenter --skill "services-layer"

Install specific skill from multi-skill repository

# Description

Service layer patterns with createTaggedError, namespace exports, and Result types. Use when creating new services, defining domain-specific errors, or understanding the service architecture.

# SKILL.md


name: services-layer
description: Service layer patterns with createTaggedError, namespace exports, and Result types. Use when creating new services, defining domain-specific errors, or understanding the service architecture.
metadata:
author: epicenter
version: '1.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.

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 Tagged Errors with createTaggedError

Every service defines domain-specific errors using createTaggedError from wellcrafted:

import { createTaggedError } from 'wellcrafted/error';
import { Err, Ok, type Result, tryAsync } from 'wellcrafted/result';

// Basic pattern - creates both constructor and Err helper
export const { MyServiceError, MyServiceErr } =
    createTaggedError('MyServiceError');
type MyServiceError = ReturnType<typeof MyServiceError>;

What createTaggedError Returns

createTaggedError('Name') returns an object with two properties:

  1. NameError - Constructor function for creating error objects
  2. NameErr - Helper that wraps the error in Err() for direct return
// These are equivalent:
return Err(MyServiceError({ message: 'Something failed' }));
return MyServiceErr({ message: 'Something failed' }); // Shorter form

Adding Typed Context with .withContext()

For errors that need structured metadata (like HTTP status codes), chain .withContext<T>():

type ResponseContext = {
    status: number; // HTTP status code
};

export const { ResponseError, ResponseErr } =
    createTaggedError('ResponseError').withContext<ResponseContext>();

// Usage: Include context when creating errors
return ResponseErr({
    message: 'Request failed',
    context: { status: 401 }, // TypeScript enforces this shape
});

Error Type Examples from the Codebase

// Simple service error (most common)
export const { RecorderServiceError, RecorderServiceErr } = createTaggedError(
    'RecorderServiceError',
);

// HTTP errors with status context
export const { ResponseError, ResponseErr } = createTaggedError(
    'ResponseError',
).withContext<{ status: number }>();

// Multiple related errors
export const { ConnectionError, ConnectionErr } =
    createTaggedError('ConnectionError');
export const { ParseError, ParseErr } = createTaggedError('ParseError');

// Combine into union type
export type HttpServiceError = ConnectionError | ResponseError | ParseError;

Service Implementation Pattern

Basic Service Structure

import { createTaggedError, extractErrorMessage } from 'wellcrafted/error';
import { Err, Ok, type Result, tryAsync, trySync } from 'wellcrafted/result';

// 1. Define domain-specific error type
export const { MyServiceError, MyServiceErr } =
    createTaggedError('MyServiceError');
type MyServiceError = ReturnType<typeof MyServiceError>;

// 2. Create factory function that returns service object
export function createMyService() {
    return {
        async doSomething(options: {
            param1: string;
            param2: number;
        }): Promise<Result<OutputType, MyServiceError>> {
            // Input validation
            if (!options.param1) {
                return MyServiceErr({
                    message: 'param1 is required',
                });
            }

            // Wrap risky operations with tryAsync
            const { data, error } = await tryAsync({
                try: () => riskyAsyncOperation(options),
                catch: (error) =>
                    MyServiceErr({
                        message: `Operation failed: ${extractErrorMessage(error)}`,
                    }),
            });

            if (error) return Err(error);
            return Ok(data);
        },
    };
}

// 3. Export the "Live" instance (production singleton)
export type MyService = ReturnType<typeof createMyService>;
export const MyServiceLive = createMyService();

Real-World Example: Recorder Service

// From apps/whispering/src/lib/services/isomorphic/recorder/navigator.ts
export function createNavigatorRecorderService(): RecorderService {
    let activeRecording: ActiveRecording | null = null;

    return {
        getRecorderState: async (): Promise<
            Result<WhisperingRecordingState, RecorderServiceError>
        > => {
            return Ok(activeRecording ? 'RECORDING' : 'IDLE');
        },

        startRecording: async (
            params: NavigatorRecordingParams,
            { sendStatus },
        ): Promise<Result<DeviceAcquisitionOutcome, RecorderServiceError>> => {
            // Validate state
            if (activeRecording) {
                return RecorderServiceErr({
                    message:
                        'A recording is already in progress. Please stop the current recording.',
                });
            }

            // Get stream (calls another service)
            const { data: streamResult, error: acquireStreamError } =
                await getRecordingStream({ selectedDeviceId, sendStatus });

            if (acquireStreamError) {
                return RecorderServiceErr({
                    message: acquireStreamError.message,
                });
            }

            // Initialize MediaRecorder
            const { data: mediaRecorder, error: recorderError } = trySync({
                try: () =>
                    new MediaRecorder(stream, {
                        bitsPerSecond: Number(bitrateKbps) * 1000,
                    }),
                catch: (error) =>
                    RecorderServiceErr({
                        message: `Failed to initialize recorder. ${extractErrorMessage(error)}`,
                    }),
            });

            if (recorderError) {
                cleanupRecordingStream(stream);
                return Err(recorderError);
            }

            // Store state and start
            activeRecording = {
                recordingId,
                stream,
                mediaRecorder,
                recordedChunks: [],
            };
            mediaRecorder.start(TIMESLICE_MS);

            return Ok(deviceOutcome);
        },
    };
}

export const NavigatorRecorderServiceLive = createNavigatorRecorderService();

Namespace Exports Pattern

Services are organized hierarchically and re-exported as namespace objects:

Folder Structure

services/
β”œβ”€β”€ desktop/           # Desktop-only (Tauri)
β”‚   β”œβ”€β”€ index.ts       # Re-exports as desktopServices
β”‚   β”œβ”€β”€ command.ts
β”‚   └── ffmpeg.ts
β”œβ”€β”€ isomorphic/        # Cross-platform
β”‚   β”œβ”€β”€ index.ts       # Re-exports as services
β”‚   β”œβ”€β”€ transcription/
β”‚   β”‚   β”œβ”€β”€ index.ts   # Re-exports as transcriptions namespace
β”‚   β”‚   β”œβ”€β”€ cloud/
β”‚   β”‚   β”‚   β”œβ”€β”€ openai.ts
β”‚   β”‚   β”‚   └── groq.ts
β”‚   β”‚   └── local/
β”‚   β”‚       └── whispercpp.ts
β”‚   └── completion/
β”‚       β”œβ”€β”€ index.ts
β”‚       └── openai.ts
β”œβ”€β”€ types.ts
└── index.ts           # Main entry point

Index File Pattern

// services/isomorphic/transcription/index.ts
export { OpenaiTranscriptionServiceLive as openai } from './cloud/openai';
export { GroqTranscriptionServiceLive as groq } from './cloud/groq';
export { WhispercppTranscriptionServiceLive as whispercpp } from './local/whispercpp';

// services/isomorphic/index.ts
import * as transcriptions from './transcription';
import * as completions from './completion';

export const services = {
    db: DbServiceLive,
    sound: PlaySoundServiceLive,
    transcriptions, // Namespace import
    completions, // Namespace import
} as const;

// services/index.ts (main entry)
export { services } from './isomorphic';
export { desktopServices } from './desktop';

Consuming Services

// In query layer or anywhere
import { services, desktopServices } from '$lib/services';

// Access via namespace
await services.transcriptions.openai.transcribe(blob, options);
await services.transcriptions.groq.transcribe(blob, options);
await services.db.recordings.getAll();
await desktopServices.ffmpeg.compressAudioBlob(blob, options);

Platform-Specific Services

For services that need different implementations per platform:

Define Shared Interface

// services/isomorphic/text/types.ts
export type TextService = {
    readFromClipboard(): Promise<Result<string | null, TextServiceError>>;
    copyToClipboard(text: string): Promise<Result<void, TextServiceError>>;
    writeToCursor(text: string): Promise<Result<void, TextServiceError>>;
};

Implement Per Platform

// services/isomorphic/text/desktop.ts
export function createTextServiceDesktop(): TextService {
    return {
        copyToClipboard: (text) =>
            tryAsync({
                try: () => writeText(text), // Tauri API
                catch: (error) => TextServiceErr({ message: 'Clipboard write failed' }),
            }),
    };
}

// services/isomorphic/text/web.ts
export function createTextServiceWeb(): TextService {
    return {
        copyToClipboard: (text) =>
            tryAsync({
                try: () => navigator.clipboard.writeText(text), // Browser API
                catch: (error) => TextServiceErr({ message: 'Clipboard write failed' }),
            }),
    };
}

Build-Time Platform Detection

// services/isomorphic/text/index.ts
export const TextServiceLive = window.__TAURI_INTERNALS__
    ? createTextServiceDesktop()
    : createTextServiceWeb();

Error Message Best Practices

Write error messages that are:

  • User-friendly: Explain what happened in plain language
  • Actionable: Suggest what the user can do
  • Detailed: Include technical details for debugging
// Good error messages
return RecorderServiceErr({
    message:
        'Unable to connect to the selected microphone. This could be because the device is already in use by another application, has been disconnected, or lacks proper permissions.',
});

return MyServiceErr({
    message: `Failed to parse configuration file. Please check that ${filename} contains valid JSON.`,
});

// Include technical details with extractErrorMessage
return MyServiceErr({
    message: `Database operation failed. ${extractErrorMessage(error)}`,
});

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. Name errors consistently - {ServiceName}ServiceError pattern

References

  • 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

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.