Stage 7 · Master
Phase 2 — Organization Service
REST API Design
Give the six handler methods a permanent, versioned address, and decide — deliberately, not by accident — where this API breaks pure REST for the sake of clarity.
Versioning From Day One, Not After the First Breaking Change
internal/router/router.go mounts every route under /api/v1, starting now, while there is only one version. Adding the prefix later — after external HOA-management integrations already depend on unversioned paths — would require either breaking every existing client or running two incompatible route trees side by side under time pressure. Reserving the prefix on day one costs nothing and removes that entire category of future incident.
The CRUD Group: Ordinary Resource Semantics
Create, List, Get, and Update map directly onto POST, GET, GET/:id, and PATCH against the /organizations collection — no surprises, because nothing about creating, reading, or partially updating an organization needs to be anything other than standard REST.
The Action Group: Where This API Deliberately Breaks Pure REST
Suspend and Archive are not resource updates in the REST sense — a client is not replacing or patching an organization's data, it is invoking a state-machine transition with side effects the Service Layer lesson already encoded in allowedTransitions. Modeling them as PATCH /organizations/:id with {"status":"suspended"} would let a client attempt any status value, including invalid ones, and would hide the fact that only two specific transitions are ever legal from any given state. POST /organizations/:id/suspend and /:id/archive make illegal transitions a 404 (wrong verb/path for that action) rather than a 409 discovered only after the request reaches the service layer — the API shape itself narrows what a client can even attempt.
package router
import (
"github.com/gin-gonic/gin"
"github.com/hoa-platform/backend/services/organization/internal/handler"
)
// New builds the organization service's entire route table behind a
// versioned prefix. It takes only the handler as a dependency — routing
// concerns (paths, verbs, prefixes) live here and nowhere else.
func New(orgHandler *handler.OrganizationHandler) *gin.Engine {
engine := gin.New()
engine.Use(gin.Recovery())
// A temporary smoke route, unchanged since Service Bootstrap. The
// Health Checks lesson retires this in favor of /healthz and /readyz.
engine.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "pong"})
})
v1 := engine.Group("/api/v1")
organizations := v1.Group("/organizations")
{
organizations.POST("", orgHandler.Create)
organizations.GET("", orgHandler.List)
organizations.GET("/:id", orgHandler.Get)
organizations.PATCH("/:id", orgHandler.Update)
organizations.POST("/:id/suspend", orgHandler.Suspend)
organizations.POST("/:id/archive", orgHandler.Archive)
}
return engine
}
Generating Swagger From the Handler Contract
The route table tells Gin how to dispatch requests, but client teams also need a machine-readable contract. Swaggo reads focused annotations beside each handler and generates an OpenAPI document plus an interactive Swagger UI. Generated files are artifacts, not a second source of truth: paths, DTOs, status codes, and security requirements remain next to the Go code they describe.
// Create registers a new HOA organization.
//
// @Summary Create an organization
// @Tags organizations
// @Accept json
// @Produce json
// @Param request body dto.CreateOrganizationRequest true "Organization details"
// @Success 201 {object} dto.OrganizationResponse
// @Failure 409 {object} apperr.Error
// @Failure 422 {object} apperr.Error
// @Router /organizations [post]
func (h *OrganizationHandler) Create(c *gin.Context) {
// Existing binding, service call, and response logic is unchanged.
}Document one endpoint completely before extending the same contract to the remaining five. Every response listed here already exists in this lesson's status-code contract; Swagger records that decision rather than inventing a new one.
import (
"github.com/gin-gonic/gin"
+ swaggerFiles "github.com/swaggo/files"
+ ginSwagger "github.com/swaggo/gin-swagger"
+ _ "github.com/hoa-platform/backend/services/organization/docs/swagger"
"github.com/hoa-platform/backend/services/organization/internal/handler"
)
+
func New(orgHandler *handler.OrganizationHandler) *gin.Engine {
engine := gin.New()
engine.Use(gin.Recovery())
+ engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
The blank import runs Swaggo's generated registration code. Swagger stays outside /api/v1 because it documents API resources rather than being one; production ingress can restrict this route independently.
import (
"github.com/hoa-platform/backend/services/organization/internal/db"
+ "github.com/hoa-platform/backend/services/organization/internal/handler"
+ "github.com/hoa-platform/backend/services/organization/internal/repository"
+ "github.com/hoa-platform/backend/services/organization/internal/router"
"github.com/hoa-platform/backend/services/organization/internal/server"
+ "github.com/hoa-platform/backend/services/organization/internal/service"
)
- engine := gin.New()
- engine.Use(gin.Recovery())
- engine.GET("/ping", func(c *gin.Context) {
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
- })
+ repo := repository.NewOrganizationPostgres(pool)
+ svc := service.NewOrganizationService(repo)
+ orgHandler := handler.NewOrganizationHandler(svc)
+ engine := router.New(orgHandler)
Configuration, logging, database connection, server execution, and shutdown are unchanged from Service Bootstrap and therefore omitted. The only new composition is the request path from repository through router.
The Status Code Contract This API Commits To
- POST /api/v1/organizations → 201 Created, or 409 Conflict on a duplicate slug, or 422 Unprocessable Entity on invalid input
- GET /api/v1/organizations → 200 OK with a paginated envelope, never an error for an empty result set
- GET /api/v1/organizations/:id → 200 OK, or 404 Not Found
- PATCH /api/v1/organizations/:id → 200 OK with the full updated resource, or 404 Not Found
- POST /api/v1/organizations/:id/suspend and /archive → 200 OK with the updated resource, or 409 Conflict if the transition is not allowed from the current status, or 404 Not Found
This contract is not enforced by any test yet — the Unit Testing and Integration Testing lessons later in this phase are what turn each line above into an assertion. Writing the contract down here, before those tests exist, is what gives the tests something correct to assert against.
Walking the Full Lifecycle Through the Real Router
Applied exercise
Prove the action-endpoint design actually narrows what a client can attempt
The lesson claims modeling Suspend/Archive as dedicated action endpoints, rather than a generic status field on PATCH, narrows what a client can attempt. Test that claim directly.
- Send a PATCH to /api/v1/organizations/:id with a body of {"status": "deleted"} — a status value that does not exist anywhere in model.Status.
- Confirm the request either fails validation or is silently ignored (UpdateOrganizationRequest has no Status field at all, so it cannot be set this way), and record which one happens and why.
- Contrast this with what would happen if UpdateOrganizationRequest did have a Status *string field: describe, in one sentence, what additional validation code would be needed to reject "deleted" that the current design gets for free.
Deliverable
A short note recording the actual behavior of the PATCH attempt and the one-sentence contrast.
Completion checks
- The note correctly identifies that Status is structurally absent from UpdateOrganizationRequest, so "status":"deleted" in the body is simply ignored by ShouldBindJSON rather than triggering any error.
- The contrast sentence correctly identifies that a Status *string field would need its own explicit validation against the known set of statuses.
Why are organization status transitions exposed as POST /organizations/:id/suspend and /:id/archive instead of PATCH /organizations/:id with a status field in the body?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.