services-layer

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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.
本技能文档介绍了如何在Whispering架构中实现服务。服务是纯业务逻辑,与UI无依赖,返回
Result<T, E>
类型用于错误处理。

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)
当你需要以下操作时,可应用此模式:
  • 创建带有领域特定错误处理的新服务
  • 添加带有结构化上下文(如HTTP状态码)的错误类型
  • 理解服务的组织与导出方式
  • 实现平台特定的服务变体(桌面端 vs 网页端)

Core Architecture

核心架构

Services follow a three-layer architecture: ServiceQueryUI
┌─────────────┐     ┌─────────────┐     ┌──────────────┐
│     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
服务遵循三层架构:ServiceQueryUI
┌─────────────┐     ┌─────────────┐     ┌──────────────┐
│     UI      │ --> │  RPC/Query  │ --> │   Services   │
│ Components  │     │    Layer    │     │    (Pure)    │
└─────────────┘     └─────────────┘     └──────────────┘
服务具备以下特性:
  • 纯函数式:接收明确参数,无隐藏依赖
  • 隔离性:不感知UI状态、设置或响应式存储
  • 可测试性:通过模拟参数轻松进行单元测试
  • 一致性:所有服务均返回
    Result<T, E>
    类型,实现统一错误处理

Creating Tagged Errors with createTaggedError

使用createTaggedError创建标记错误

Every service defines domain-specific errors using
createTaggedError
from wellcrafted:
typescript
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>;
每个服务都使用wellcrafted中的
createTaggedError
定义领域特定错误:
typescript
import { createTaggedError } from 'wellcrafted/error';
import { Err, Ok, type Result, tryAsync } from 'wellcrafted/result';

// 基础模式 - 同时创建构造函数和Err辅助函数
export const { MyServiceError, MyServiceErr } =
	createTaggedError('MyServiceError');
type MyServiceError = ReturnType<typeof MyServiceError>;

What createTaggedError Returns

createTaggedError的返回值

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
typescript
// These are equivalent:
return Err(MyServiceError({ message: 'Something failed' }));
return MyServiceErr({ message: 'Something failed' }); // Shorter form
createTaggedError('Name')
返回一个包含两个属性的对象:
  1. NameError
    - 用于创建错误对象的构造函数
  2. NameErr
    - 将错误包装在
    Err()
    中的辅助函数,可直接返回
typescript
// 以下两种写法等价:
return Err(MyServiceError({ message: 'Something failed' }));
return MyServiceErr({ message: 'Something failed' }); // 简写形式

Adding Typed Context with .withContext()

使用.withContext()添加类型化上下文

For errors that need structured metadata (like HTTP status codes), chain
.withContext<T>()
:
typescript
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
});
对于需要结构化元数据(如HTTP状态码)的错误,可链式调用
.withContext<T>()
typescript
type ResponseContext = {
	status: number; // HTTP状态码
};

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

// 使用方式:创建错误时包含上下文
return ResponseErr({
	message: 'Request failed',
	context: { status: 401 }, // TypeScript会强制校验该结构
});

Error Type Examples from the Codebase

代码库中的错误类型示例

typescript
// 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;
typescript
// 简单服务错误(最常见)
export const { RecorderServiceError, RecorderServiceErr } = createTaggedError(
	'RecorderServiceError',
);

// 带状态上下文的HTTP错误
export const { ResponseError, ResponseErr } = createTaggedError(
	'ResponseError',
).withContext<{ status: number }>();

// 多个相关错误
export const { ConnectionError, ConnectionErr } =
	createTaggedError('ConnectionError');
export const { ParseError, ParseErr } = createTaggedError('ParseError');

// 合并为联合类型
export type HttpServiceError = ConnectionError | ResponseError | ParseError;

Service Implementation Pattern

服务实现模式

Basic Service Structure

基础服务结构

typescript
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();
typescript
import { createTaggedError, extractErrorMessage } from 'wellcrafted/error';
import { Err, Ok, type Result, tryAsync, trySync } from 'wellcrafted/result';

// 1. 定义领域特定错误类型
export const { MyServiceError, MyServiceErr } =
	createTaggedError('MyServiceError');
type MyServiceError = ReturnType<typeof MyServiceError>;

// 2. 创建返回服务对象的工厂函数
export function createMyService() {
	return {
		async doSomething(options: {
			param1: string;
			param2: number;
		}): Promise<Result<OutputType, MyServiceError>> {
			// 输入校验
			if (!options.param1) {
				return MyServiceErr({
					message: 'param1是必填项',
				});
			}

			// 使用tryAsync包装风险操作
			const { data, error } = await tryAsync({
				try: () => riskyAsyncOperation(options),
				catch: (error) =>
					MyServiceErr({
						message: `操作失败: ${extractErrorMessage(error)}`,
					}),
			});

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

// 3. 导出"Live"实例(生产环境单例)
export type MyService = ReturnType<typeof createMyService>;
export const MyServiceLive = createMyService();

Real-World Example: Recorder Service

真实示例:Recorder服务

typescript
// 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();
typescript
// 来自 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>> => {
			// 校验状态
			if (activeRecording) {
				return RecorderServiceErr({
					message:
						'当前已有录制在进行中,请先停止当前录制。',
				});
			}

			// 获取流(调用其他服务)
			const { data: streamResult, error: acquireStreamError } =
				await getRecordingStream({ selectedDeviceId, sendStatus });

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

			// 初始化MediaRecorder
			const { data: mediaRecorder, error: recorderError } = trySync({
				try: () =>
					new MediaRecorder(stream, {
						bitsPerSecond: Number(bitrateKbps) * 1000,
					}),
				catch: (error) =>
					RecorderServiceErr({
						message: `初始化录制器失败。${extractErrorMessage(error)}`,
					}),
			});

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

			// 存储状态并开始录制
			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
services/
├── desktop/           # 仅桌面端(Tauri)
│   ├── index.ts       # 重新导出为desktopServices
│   ├── command.ts
│   └── ffmpeg.ts
├── isomorphic/        # 跨平台
│   ├── index.ts       # 重新导出为services
│   ├── transcription/
│   │   ├── index.ts   # 重新导出为transcriptions命名空间
│   │   ├── cloud/
│   │   │   ├── openai.ts
│   │   │   └── groq.ts
│   │   └── local/
│   │       └── whispercpp.ts
│   └── completion/
│       ├── index.ts
│       └── openai.ts
├── types.ts
└── index.ts           # 主入口

Index File Pattern

索引文件模式

typescript
// 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';
typescript
// 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, // 命名空间导入
	completions, // 命名空间导入
} as const;

// services/index.ts(主入口)
export { services } from './isomorphic';
export { desktopServices } from './desktop';

Consuming Services

服务使用方式

typescript
// 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);
typescript
// 在查询层或其他任意位置
import { services, desktopServices } from '$lib/services';

// 通过命名空间访问
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

定义共享接口

typescript
// 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>>;
};
typescript
// 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

按平台实现

typescript
// 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' }),
			}),
	};
}
typescript
// services/isomorphic/text/desktop.ts
export function createTextServiceDesktop(): TextService {
	return {
		copyToClipboard: (text) =>
			tryAsync({
				try: () => writeText(text), // Tauri API
				catch: (error) => TextServiceErr({ message: '剪贴板写入失败' }),
			}),
	};
}

// services/isomorphic/text/web.ts
export function createTextServiceWeb(): TextService {
	return {
		copyToClipboard: (text) =>
			tryAsync({
				try: () => navigator.clipboard.writeText(text), // 浏览器API
				catch: (error) => TextServiceErr({ message: '剪贴板写入失败' }),
			}),
	};
}

Build-Time Platform Detection

构建时平台检测

typescript
// services/isomorphic/text/index.ts
export const TextServiceLive = window.__TAURI_INTERNALS__
	? createTextServiceDesktop()
	: createTextServiceWeb();
typescript
// 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
typescript
// 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)}`,
});
编写错误消息时应满足:
  • 用户友好:用平实语言解释问题
  • 可操作:给出用户可执行的建议
  • 详细:包含便于调试的技术细节
typescript
// 优秀的错误消息示例
return RecorderServiceErr({
	message:
		'无法连接到所选麦克风。可能是该设备已被其他应用占用、已断开连接或缺少必要权限。',
});

return MyServiceErr({
	message: `解析配置文件失败,请检查${filename}是否包含有效的JSON格式。`,
});

// 使用extractErrorMessage包含技术细节
return MyServiceErr({
	message: `数据库操作失败。${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
  1. 服务从不导入配置 - 配置需作为参数传入
  2. 服务从不导入UI代码 - 无提示框、无通知、无WhisperingError
  3. 始终返回Result类型 - 绝不能抛出错误
  4. 使用trySync/tryAsync - 详情请查看错误处理技能文档
  5. 导出工厂函数+Live实例 - 工厂函数用于测试,Live实例用于生产环境
  6. 错误命名保持一致 - 遵循
    {ServiceName}ServiceError
    命名模式

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
  • 查看
    apps/whispering/src/lib/services/README.md
    了解架构细节
  • 查看
    query-layer
    技能文档了解服务的使用方式
  • 查看
    error-handling
    技能文档了解trySync/tryAsync模式