一键导入
service
Create or modify a service package following OpenMeter conventions. Use when building new domain packages or modifying existing service/adapter layers.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Create or modify a service package following OpenMeter conventions. Use when building new domain packages or modifying existing service/adapter layers.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Work on OpenMeter currency primitives in pkg/currencyx for fiat and custom currency codes, shared currency interfaces, rounding modes, calculators, allocation precision, fiat/custom boundaries, and callers in billing, charges, ledger, product catalog, subscriptions, API, or currency registry code.
Work with OpenMeter billing charges, including the root charges facade, charge meta queries, charge creation and advancement, usage-based lifecycle state machines, realization runs, and charges test setup. Use when modifying `openmeter/billing/charges/...` or charge-related tests.
Write end-to-end tests for OpenMeter against a live server. Use when adding tests under e2e/ that exercise API endpoints over HTTP (v1 generated SDK or v3 raw HTTP).
Work with the OpenMeter billing package. Use this skill whenever touching invoice lifecycle, billing profiles, customer overrides, invoice line items, gathering invoices, standard invoices, the invoice state machine, billing validation issues, billing-subscription sync, the billing worker, invoice calculation, rating/pricing engine, or tax config on billing objects. Also use when writing or debugging billing integration tests (BaseSuite, SubscriptionMixin), billing adapter (Ent queries), billing HTTP handlers, or the subscription→billing sync algorithm. Trigger this skill for any file under `openmeter/billing/`, `openmeter/billing/worker/`, `openmeter/billing/service/`, `openmeter/billing/adapter/`, `openmeter/billing/rating/`, `test/billing/`, or `cmd/billing-worker/`.
Use when writing or refactoring Go collection/pointer helper code in OpenMeter, especially when choosing between standard library slices/maps helpers and github.com/samber/lo for cloning, copying, equality, sorting, pointer literals, slice-to-map transforms, map keys/values, mapping, filtering, grouping, uniqueness, set-like conversions, and map entry transformations.
Add or modify API endpoints using TypeSpec. Use when adding new API routes, modifying request/response types, or changing the OpenAPI spec.
| name | service |
| description | Create or modify a service package following OpenMeter conventions. Use when building new domain packages or modifying existing service/adapter layers. |
| user-invocable | true |
| argument-hint | [description of service to create or modify] |
| allowed-tools | Read, Edit, Write, Bash, Grep, Glob, Agent |
You are helping the user create or modify a service package in OpenMeter following established conventions.
Each domain package lives under openmeter/<domain>/ and follows this structure:
openmeter/<domain>/
├── service.go # Service interface definition
├── adapter.go # Adapter interface definition
├── <domain>.go # Domain types and models
├── errors.go # Custom errors (optional, only when needed)
├── event.go # Domain events (optional, for packages that modify DB entities)
├── adapter/ # Adapter layer implementation (data access)
│ ├── adapter.go # Config, New(), transaction boilerplate
│ ├── <operation>.go # One file per operation (list.go, get.go, create.go, etc.)
│ └── mapping.go # Entity ↔ domain type mapping functions
├── service/ # Service layer implementation (business logic + orchestration)
│ └── service.go
├── driver/ # v1 API, do not implement for new services (also called: httpdriver, driver)
│ └── <operation>.go
api/v3/handlers/<domain>/
└── <api_operation>/ # The API operation defined in API spec
Defines the public API of the domain. This is what other packages depend on.
See openmeter/customer/service.go and openmeter/llmcost/service.go for examples.
package <domain>
type Service interface {
List<Resource>s(ctx context.Context, input List<Resource>sInput) (pagination.Result[<Resource>], error)
Create<Resource>(ctx context.Context, input Create<Resource>Input) (*<Resource>, error)
Get<Resource>(ctx context.Context, input Get<Resource>Input) (*<Resource>, error)
Update<Resource>(ctx context.Context, input Update<Resource>Input) (*<Resource>, error)
Delete<Resource>(ctx context.Context, input Delete<Resource>Input) error
}
Defines the persistence layer contract. Implements DB access using ent ORM.
See openmeter/customer/adapter.go and openmeter/llmcost/adapter.go for examples.
package <domain>
type Adapter interface {
entutils.TxCreator
// Same methods as Service, plus any internal-only persistence methods
}
The adapter interface typically mirrors the service interface but may include additional internal methods (e.g., UpsertGlobalPrice).
All input structs MUST have a Validate() method. Follow these patterns:
models.NewNillableGenericValidationError(errors.Join(errs...)) to return validation errorsmodels.Validator interface (compile-time check with var _ models.Validator = (*MyInput)(nil))See openmeter/llmcost/service.go for comprehensive validation examples.
var _ models.Validator = (*Create<Resource>Input)(nil)
type Create<Resource>Input struct {
Namespace string
Name string
}
func (i Create<Resource>Input) Validate() error {
var errs []error
if i.Namespace == "" {
errs = append(errs, fmt.Errorf("namespace is required"))
}
if i.Name == "" {
errs = append(errs, fmt.Errorf("name is required"))
}
return models.NewNillableGenericValidationError(errors.Join(errs...))
}
Use models.NamespacedID as the standard way to identify namespaced entities:
type GetItemInput struct {
models.NamespacedID // provides Namespace + ID with built-in Validate()
}
type DeleteItemInput struct {
models.NamespacedID
}
Reference: pkg/models/id.go, used in openmeter/subject/service.go
| Layer | Owns | Does NOT own |
|---|---|---|
| Root | Types, interfaces, input DTOs, errors, validation | Any implementation code |
| Service | Business rules, transaction orchestration, input enrichment | Database queries, entity mapping |
| Adapter | Ent queries, entity↔domain mapping, constraint error handling | Business decisions, input defaults |
service/)The service layer orchestrates operations: validates inputs, applies business rules, and wraps calls in transactions. It is thin when the operation is pure CRUD, but substantial when business logic exists. It:
Real examples of business logic in the service layer:
customer/service/customer.go — checks active subscriptions before allowing deletellmcost/service/service.go — fetches global prices + namespace overrides, merges in memorycurrencies/service/service.go — mixes in-memory fiat currencies with DB-stored custom onesSee openmeter/customer/service/customer.go for a full example with hooks and events.
See openmeter/llmcost/service/service.go for a simpler passthrough example.
Constructor patterns:
func New(adapter <domain>.Adapter, logger *slog.Logger) <domain>.Servicefunc New(config Config) (*Service, error) where Config has a Validate() methodUse transaction.Run() for methods returning a value, transaction.RunWithNoValue() for void methods:
func (s *service) Create<Resource>(ctx context.Context, input <domain>.Create<Resource>Input) (*<domain>.<Resource>, error) {
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (*<domain>.<Resource>, error) {
result, err := s.adapter.Create<Resource>(ctx, input)
if err != nil {
return nil, err
}
// Publish event, call hooks, etc.
return result, nil
})
}
func (s *service) Delete<Resource>(ctx context.Context, input <domain>.Delete<Resource>Input) error {
return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error {
return s.adapter.Delete<Resource>(ctx, input)
})
}
Reference: openmeter/llmcost/service/service.go, openmeter/customer/service/customer.go
For services that need lifecycle hooks (e.g., other services reacting to creates/deletes), use models.ServiceHookRegistry:
type Service struct {
adapter <domain>.Adapter
publisher eventbus.Publisher
hooks models.ServiceHookRegistry[<domain>.<Resource>]
}
func (s *Service) RegisterHooks(hooks ...models.ServiceHook[<domain>.<Resource>]) {
s.hooks.RegisterHooks(hooks...)
}
Available hook points: PostCreate, PreDelete, PostDelete, PreUpdate, PostUpdate. Call them inside the transaction:
// In CreateCustomer:
if err = s.hooks.PostCreate(ctx, created); err != nil {
return nil, err
}
// In DeleteCustomer:
if err = s.hooks.PreDelete(ctx, existing); err != nil {
return err
}
// ... perform delete ...
if err = s.hooks.PostDelete(ctx, deleted); err != nil {
return err
}
Reference: openmeter/customer/service/service.go, openmeter/customer/service/customer.go
adapter/)The adapter is pure data access: it translates between the domain model and the database (Ent ORM). It contains no business logic — if a rule is not about "how to store or retrieve data," it belongs in the service. It:
Adapter interfaceTx, WithTx, Self)entutils.TransactingRepo() for transaction supportmapping.go)db.IsNotFound() → NewXxxNotFoundError())input.Validate() when the service layer is a passthrough (no additional validation)See openmeter/customer/adapter/ and openmeter/llmcost/adapter/ for examples.
Every adapter MUST implement these three methods. Copy from openmeter/llmcost/adapter/adapter.go:61-83:
func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) {
ctx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{
ReadOnly: false,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err)
}
return ctx, entutils.NewTxDriver(eDriver, rawConfig), nil
}
func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter {
txClient := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig())
return &adapter{
db: txClient.Client(),
logger: a.logger,
}
}
func (a *adapter) Self() *adapter {
return a
}
Each adapter method wraps its logic in entutils.TransactingRepo() (or TransactingRepoWithNoValue() for void):
func (a *adapter) List<Resource>s(ctx context.Context, input <domain>.List<Resource>sInput) (pagination.Result[<domain>.<Resource>], error) {
return entutils.TransactingRepo(ctx, a, func(ctx context.Context, a *adapter) (pagination.Result[<domain>.<Resource>], error) {
if err := input.Validate(); err != nil {
return pagination.Result[<domain>.<Resource>]{}, err
}
query := a.db.<Entity>.Query().
Where(<entity>db.DeletedAtIsNil()) // Always filter soft-deleted
// Apply ordering
order := entutils.GetOrdering(sortx.OrderDefault)
if !input.Order.IsDefaultValue() {
order = entutils.GetOrdering(input.Order)
}
switch input.OrderBy {
case "id":
query = query.Order(<entity>db.ByID(order...))
default:
query = query.Order(<entity>db.ByID())
}
// Paginate
entities, err := query.Paginate(ctx, input.Page)
if err != nil {
return pagination.Result[<domain>.<Resource>]{}, fmt.Errorf("failed to list: %w", err)
}
return pagination.MapResultErr(entities, map<Resource>FromEntity)
})
}
For void operations, use entutils.TransactingRepoWithNoValue():
func (a *adapter) Delete<Resource>(ctx context.Context, input <domain>.Delete<Resource>Input) error {
return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, a *adapter) error {
// ...
})
}
Reference: openmeter/llmcost/adapter/price.go
mapping.go)Create mapping.go with functions that convert ent entities to domain types:
func map<Resource>FromEntity(entity *db.<Entity>) (<domain>.<Resource>, error) {
if entity == nil {
return <domain>.<Resource>{}, errors.New("entity is required")
}
return <domain>.<Resource>{
ManagedModel: models.ManagedModel{
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
DeletedAt: entity.DeletedAt,
},
ID: entity.ID,
Name: entity.Name,
// ... map all fields
}, nil
}
For paginated results, use pagination.MapResultErr(entities, mapFn).
Reference: openmeter/llmcost/adapter/mapping.go
errors.go)Only create custom errors when they bring real value and visibility. All custom errors MUST inherit from generic errors in pkg/models/errors.go.
Available generic error types:
models.NewGenericNotFoundError(err) — resource not foundmodels.NewGenericConflictError(err) — conflict (duplicate key, etc.)models.NewGenericValidationError(err) — input validation failuremodels.NewGenericForbiddenError(err) — authorization failuremodels.NewGenericPreConditionFailedError(err) — precondition not metmodels.NewGenericUnauthorizedError(err) — authentication failuremodels.NewGenericNotImplementedError(err) — not implementedmodels.NewGenericStatusFailedDependencyError(err) — dependency failureSee openmeter/customer/errors.go for the error pattern:
type MyCustomError struct {
err error
}
func (e MyCustomError) Error() string { return e.err.Error() }
func (e MyCustomError) Unwrap() error { return e.err }
Each custom error should:
pkg/models/errors.gomodels.GenericError interfaceNewMyCustomError(...))Is check function (IsMyCustomError(err error) bool) if neededevent.go)Packages that modify database entities should emit domain events. See openmeter/customer/event.go for the full pattern.
Events follow this structure:
metadata.EventSubsystem and metadata.EventNameEventName() string and EventMetadata() metadata.EventMetadataValidate() methodNewCustomerCreateEvent(ctx, customer)When the service requires database tables, use the /db-migration skill for creating ent schemas and generating migrations.
When implementing API handlers for the service, use the /api skill for handler implementation patterns, wiring into the server, and type conversion.
Services are wired together using Wire for dependency injection.
app/common/Create a file app/common/<domain>.go that defines a Wire provider set and a constructor function. This is where the adapter and service are instantiated and connected.
See app/common/llmcost.go for a simple example and app/common/customer.go for a more complex one with hooks.
package common
import (
"fmt"
"log/slog"
"github.com/google/wire"
entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
"<domain>"
<domain>adapter "<domain>/adapter"
<domain>service "<domain>/service"
)
var <Domain> = wire.NewSet(
New<Domain>Service,
)
func New<Domain>Service(logger *slog.Logger, db *entdb.Client) (<domain>.Service, error) {
adapter, err := <domain>adapter.New(<domain>adapter.Config{
Client: db,
Logger: logger.With("subsystem", "<domain>"),
})
if err != nil {
return nil, fmt.Errorf("failed to initialize <domain> adapter: %w", err)
}
return <domain>service.New(adapter, logger.With("subsystem", "<domain>")), nil
}
Key patterns:
logger.With("subsystem", "<domain>") for structured loggingeventbus.Publishercmd/<micro_service>/wire.goAdd the service to the Application struct and include the Wire provider set in wire.Build():
Application struct:type Application struct {
// ...
<Domain>Service <domain>.Service
}
wire.Build():func initializeApplication(ctx context.Context, conf config.Configuration) (Application, func(), error) {
wire.Build(
// ...
common.<Domain>,
// ...
)
}
make generate to regenerate wire_gen.goIf the service is needed in other entry points (e.g., cmd/billing-worker, cmd/balance-worker), add it to their wire.go files as well. Check which cmd/*/wire.go files need the service based on its consumers.
openmeter/<domain>/<domain>.goService interface in service.go with input types and their Validate() methodsAdapter interface in adapter.goservice/service.go/db-migration skill)adapter/adapter.go, adapter/<operation>.go, adapter/mapping.goerrors.go only if custom errors are neededevent.go if the service modifies entitiesapp/common/<domain>.go and register in cmd/<micro_service>/wire.gomake generate to regenerate Wire bindings/api skill)Service and Adapter interfacesValidate() methodsservice/ and adapter/ layersEvery entity is namespaced. In HTTP handlers, extract namespace via namespaceDecoder.GetNamespace(ctx). In service/adapter layers, namespace is always passed as part of the input struct.
Use *slog.Logger everywhere with structured context logging:
logger.WarnContext(ctx, "msg", "key", val)
Use logger.With("subsystem", "<domain>") when creating sub-loggers for services/adapters.
adapter/, service/). No adapter/internal/helpers/.api/v3/handlers/, not as subpackages of the domain.