threedotslab/references/rules-ports.md

4.1 KiB

Port Rules (PORT-01..05)

PORT-01: Handler Struct Holds Application (WARNING)

HTTP and gRPC handler structs MUST hold app.Application and delegate to it. They are thin wrappers.

// ports/http.go
type HttpServer struct {
    app app.Application
}

// ports/grpc.go
type GrpcServer struct {
    app app.Application
}

PORT-02: Error Mapping (WARNING)

Ports MUST map application errors to protocol-specific responses. They must NOT leak internal error details.

HTTP — using httperr helper:

func (h HttpServer) MakeHourAvailable(w http.ResponseWriter, r *http.Request) {
    err = h.app.Commands.MakeHoursAvailable.Handle(r.Context(), command.MakeHoursAvailable{...})
    if err != nil {
        httperr.RespondWithSlugError(err, w, r)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

The httperr mapper:

func RespondWithSlugError(err error, w http.ResponseWriter, r *http.Request) {
    slugError, ok := err.(errors.SlugError)
    if !ok {
        InternalError("internal-server-error", err, w, r)
        return
    }
    switch slugError.ErrorType() {
    case errors.ErrorTypeAuthorization:
        Unauthorised(slugError.Slug(), slugError, w, r)  // 401
    case errors.ErrorTypeIncorrectInput:
        BadRequest(slugError.Slug(), slugError, w, r)     // 400
    default:
        InternalError(slugError.Slug(), slugError, w, r)  // 500
    }
}

gRPC — using status codes:

func (g GrpcServer) ScheduleTraining(ctx context.Context, req *trainer.UpdateHourRequest) (*empty.Empty, error) {
    if err := g.app.Commands.ScheduleTraining.Handle(ctx, command.ScheduleTraining{...}); err != nil {
        return nil, status.Error(codes.Internal, err.Error())
    }
    return &empty.Empty{}, nil
}

PORT-03: Auth Extracted from Context (WARNING)

Authentication/authorization data MUST be extracted from the request context using a shared auth package, NOT parsed directly in the handler.

Correct:

func (h HttpServer) MakeHourAvailable(w http.ResponseWriter, r *http.Request) {
    user, err := auth.UserFromCtx(r.Context())
    if err != nil {
        httperr.RespondWithSlugError(err, w, r)
        return
    }
    if user.Role != "trainer" {
        httperr.Unauthorised("invalid-role", nil, w, r)
        return
    }
    // ... delegate to app
}

Wrong:

func (h HttpServer) MakeHourAvailable(w http.ResponseWriter, r *http.Request) {
    token := r.Header.Get("Authorization")          // VIOLATION: parsing auth in handler
    claims, err := jwt.Parse(token, keyFunc)         // VIOLATION: JWT logic in port
    // ...
}

PORT-04: No Business Logic in Ports (CRITICAL)

Port handlers MUST only:

  1. Parse/decode the request
  2. Extract auth from context
  3. Construct command/query struct
  4. Call app.Commands.X.Handle() or app.Queries.X.Handle()
  5. Map the result/error to a response

They MUST NOT contain:

  • Domain validation logic
  • Business rule checks
  • Direct database calls
  • State manipulation

Check: Port files should only import app/, app/command/, app/query/, and infrastructure packages (HTTP, gRPC, auth). They should NOT import domain/ directly (except for response mapping types).


PORT-05: Response Model Mapping (INFO)

Response transformation SHOULD be in separate mapping functions, not inline in handlers.

// Mapping function
func dateModelsToResponse(models []query.Date) []Date {
    var dates []Date
    for _, m := range models {
        dates = append(dates, Date{
            Date:  m.Date,
            Hours: hourModelsToResponse(m.Hours),
        })
    }
    return dates
}

// Handler uses it cleanly
func (h HttpServer) GetTrainerAvailableHours(w http.ResponseWriter, r *http.Request, params GetTrainerAvailableHoursParams) {
    dateModels, err := h.app.Queries.TrainerAvailableHours.Handle(r.Context(), query.AvailableHours{
        From: params.DateFrom,
        To:   params.DateTo,
    })
    if err != nil {
        httperr.RespondWithSlugError(err, w, r)
        return
    }
    dates := dateModelsToResponse(dateModels)
    render.Respond(w, r, dates)
}