services-layer

安装量: 44
排名: #16622

安装

npx skills add https://github.com/epicenterhq/epicenter --skill services-layer
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
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
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:
NameError
- Constructor function for creating error objects
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()
:
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 Services never import settings - Pass configuration as parameters Services never import UI code - No toasts, no notifications, no WhisperingError Always return Result types - Never throw errors Use trySync/tryAsync - See the error-handling skill for details Export factory + Live instance - Factory for testing, Live for production 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
返回排行榜