Services
A service owns every database read and write for its resource. The one grit generate resource writes is complete: list, export, get, create, update, patch, delete and bulk, with the resource's whitelists, ownership rules and transactions. The handler calls it, and so can a job, a command or a test.
Handler or Service?
| Concern | Where |
|---|---|
| Binding and validating the request body | Handler |
| Status codes, headers, rendering CSV or PDF | Handler |
| Any query, however simple | Service |
| Several writes that must land together | Service, in a transaction |
| Who may see or change a row | Service, from the caller on the context |
| Business rules beyond binding tags | Service, returning respond.Rule |
| External calls: email, storage, AI | A dedicated service |
The Generated Service
Services live in apps/api/internal/services/, one file per resource. The top of a generated one:
// PostService owns every database read and write for posts.type PostService struct {DB *gorm.DB}// postListConfig is what a client may search, sort and filter posts// by. Whitelisted, because each name ends up in SQL.var postListConfig = paginate.Config{Searchable: []string{"title", "body"},Sortable: map[string]bool{"id": true, "created_at": true, "title": true, "published": true},Filterable: map[string]bool{"id": true, "title": true, "published": true},}// writablePost is every column Patch and Bulk may write. id, the// timestamps and the version are the framework's, and are dropped.var writablePost = map[string]bool{"title": true,"body": true,"published": true,}// db binds the database to ctx, so whatever a middleware put there reaches// GORM's callbacks: the multitenant plugin scopes by it.func (s *PostService) db(ctx context.Context) *gorm.DB {return s.DB.WithContext(ctx)}
And the methods it comes with:
List(ctx, p paginate.Params, archived string) (paginate.Result[models.Post], error)Export(ctx, search string, each func(rows []models.Post) error) errorGetByID(ctx, id string) (*models.Post, error)Create(ctx, item *models.Post) errorUpdate(ctx, id string, updates map[string]interface{}, pre *concurrency.Precondition) (*models.Post, error)Patch(ctx, id string, body map[string]interface{}, pre *concurrency.Precondition) (*models.Post, map[string]interface{}, error)Delete(ctx, id string) (*models.Post, error)Bulk(ctx, action string, ids []string, patch map[string]interface{}) (PostBulkResult, error)
grit generate field adds a new column to the whitelists as well as the model and the handler, so a field added later can be filtered and patched like one generated with the resource.
The flags add their own queries, in the same place:
--publicaddsListPublic,GetPublic,RelatedPublicwhen the resource has a parent, andPublicSubtreeIDsandPublicTreeRowsfor a tree. What a caller may filter by stays in the public handler, where you edit it, and is handed toListPublic.- The CSV import lives in
services/post_import.go:StartImportrecords the job, andImportCSVreads the rows, resolves their relations and writes them in batches. It runs after the response, so the handler gives itcontext.WithoutCancel(h.ctx(c)): the caller and the organization, without the cancellation. --treehas its hierarchy on aPostTreeServiceinservices/post_tree.go, whose methods take the context too.Movewith a nil parent keeps the one the node has, which is how a reorder is sent.
The Context Carries the Caller
Every method takes a context.Context first. It carries who is asking, which an owned resource (--owned-by) scopes its rows by, as well as the organization the multitenant plugin resolved and the cancellation when a client goes away.
// In a handler. The generated ctx helper does exactly this.ctx := authz.WithActor(c.Request.Context(), authz.ActorOf(c))// In a job or a command, acting for the system: every row, like ADMIN.ctx := authz.AsSystem(context.Background())// Acting for one user, so owned rows are scoped to them.ctx := authz.WithActor(context.Background(), authz.Actor{UserID: userID})
On an owned resource a context with no caller on it matches nothing. That is on purpose: a job that forgot to say who it acts for gets an empty list, not every user's rows. Somebody else's row comes back as gorm.ErrRecordNotFound, so a wrong guess at an id cannot be told from a right one.
svc := &services.InvoiceService{DB: db}ctx := authz.AsSystem(context.Background())// nil precondition: no version check. A job is not editing a copy it read earlier.if _, err := svc.Update(ctx, id, map[string]interface{}{"status": "overdue"}, nil); err != nil {return fmt.Errorf("marking invoice %s overdue: %w", id, err)}
Writes and Versions
Update and Patch take a *concurrency.Precondition. A handler builds it from the request's If-Match header with concurrency.FromRequest(c); with one, the write lands only if the row is still at that version, and otherwise the method returns a *concurrency.ErrConflict naming the version it is at. nil means no check.
Update writes the columns in the map it is given. The generated handler builds that map from its typed request; if you call it yourself, use column names. Patch takes a raw body and keeps only the writable columns.
Adding Your Own Methods
Put them in a file of your own in the same package, such as services/invoice_billing.go. It is the same type, so your methods can use s.db(ctx) and the owner-checked s.load, and regenerating the resource never touches your file.
// MarkPaid records a payment. The rule lives here, so the route, the// nightly reconciliation job and a test all get the same answer.func (s *InvoiceService) MarkPaid(ctx context.Context, id string) (*models.Invoice, error) {item, err := s.load(ctx, id) // not found if the caller may not see itif err != nil {return nil, err}if item.PaidAt != nil {return nil, respond.Rule("invoice %s is already paid", item.Number) // 422}now := time.Now()if err := s.db(ctx).Model(item).Update("paid_at", now).Error; err != nil {return nil, fmt.Errorf("marking invoice paid: %w", err)}item.PaidAt = &nowreturn item, nil}
The handler for it is four lines of HTTP:
func (h *InvoiceHandler) MarkPaid(c *gin.Context) {item, err := h.service().MarkPaid(h.ctx(c), c.Param("id"))if err != nil {h.fail(c, err, "Failed to mark invoice paid")return}c.JSON(http.StatusOK, gin.H{"data": item, "message": "Invoice marked paid"})}
Transactions
When a method makes several writes that must succeed or fail together, run them in a GORM transaction. The generated service does this wherever a write has more than one statement: a row and its many-to-many links, a row and its line items, a bulk action.
// CreateOrder creates an order and decrements product stock atomically.func (s *OrderService) CreateOrder(ctx context.Context, order *models.Order, items []models.OrderItem) error {return s.db(ctx).Transaction(func(tx *gorm.DB) error {if err := tx.Create(order).Error; err != nil {return fmt.Errorf("creating order: %w", err)}for i := range items {items[i].OrderID = order.IDif err := tx.Create(&items[i]).Error; err != nil {return fmt.Errorf("creating order item: %w", err)}result := tx.Model(&models.Product{}).Where("id = ? AND stock >= ?", items[i].ProductID, items[i].Quantity).Update("stock", gorm.Expr("stock - ?", items[i].Quantity))if result.Error != nil {return fmt.Errorf("updating stock: %w", result.Error)}if result.RowsAffected == 0 {return respond.Rule("not enough stock for product %s", items[i].ProductID)}}return nil // commit})}
- Use
txfor every query inside the callback, nots.DB. - Returning
nilcommits; returning an error rolls everything back. - A panic inside the callback is recovered and rolled back.
- Start from
s.db(ctx), so the transaction carries the request's context.
Errors
Return Go errors, never HTTP codes; the handler's fail picks the status. Three have a meaning of their own:
gorm.ErrRecordNotFoundbecomes a 404.*concurrency.ErrConflictbecomes a 409 naming the current version.respond.Rule("...")becomes a 422 carrying your message: use it for a rule the caller broke. Anything else is logged and answered with a plain 500, so wrap it withfmt.Errorf("context: %w", err)for the log.
Best Practices
- One service per resource, each in its own file, with your additions in files of your own beside it.
- Context first, always. Query through
s.db(ctx); a query ons.DBdirectly cannot see the caller, the organization or a cancelled request. - Say who a job acts for.
authz.AsSystemorauthz.WithActor; a barecontext.Background()sees nothing of an owned resource. - Keep services independent. When two need to collaborate, the caller orchestrates them.
