Adding a New Endpoint: Architectural Pattern & Workflow ¶
This guide outlines the standard practice for implementing a new API endpoint within our Go backend. Our architecture follows a modular approach, heavily utilizing Interface Segregation and the Adapter Pattern to ensure our code remains testable and decoupled.
Architectural Philosophy ¶
Before writing code, understand why we do it this way:
- Consumer-Defined Interfaces: Layers define the interfaces they need. The Service layer defines what it needs from a Client; the API layer defines what it needs from a Service.
- The "Big" Client vs. The "Contract": We instantiate "Big" clients (WorkOS, LiveKit) at the application root (
app.go), but our services only accept narrow interfaces (Contracts). - Testability: By generating mocks for these narrow interfaces, we can unit test every layer in isolation without spinning up actual external dependencies.
Workflow Summary ¶
- Client Wrapper (Optional): Expose functionality from external SDKs if missing.
- Service Layer: Define the logic and the external dependencies (Contracts).
- API Layer: Define the HTTP handler, input validation, and Service dependency.
- Module Wiring: Wire the API handler to the Service implementation.
- App Wiring: Register the route in
/internal/app/app.go.
Step 1: Client Wrapper (Platform Layer) ¶
Location: /internal/platform/{provider}/client.go
Do this step only if the external SDK method you need is not yet exposed by our Wrapper.
We do not use 3rd party SDKs directly in our features. We wrap them to allow for mocking and standardizing error handling.
The LiveKit Special Case (Adapter Pattern) ¶
LiveKit's Go SDK uses concrete structs that are hard to mock. We use an Adapter Pattern:
1. Add the method signature to the Client interface.
2. Add the method signature to the internal sdkClient interface.
3. Implement the delegation in sdkAdapter.
4. Implement the facade logic in clientImpl.
The WorkOS Case (Facade) ¶
WorkOS is friendlier.
1. Add the method signature to the Client interface.
2. Implement the method in clientImpl, ensuring errors are mapped using our standardized error mapper.
Mock Generation
Always run go generate ./... after modifying the Client interface to update the mocks in /mocks.
Step 2: Service Layer (Business Logic) ¶
Location: /internal/features/{feature}/service/
This is where the core logic lives. We adhere strictly to Interface Segregation.
1. Define the Contract (Interface) ¶
In your service file (e.g., membership_service.go), do not import the platform package's interface. Instead, define a local interface listing only the methods this specific service needs.
// internal/features/membership/service/service.go
// MembershipClient is a "Contract".
// Even though the actual WorkOS client has 50 methods, this service only needs 3.
//
//go:generate mockery --name=MembershipClient --output ./mocks --case=underscore
type MembershipClient interface {
DeleteOrganizationMembership(ctx context.Context, organizationMembershipId string) error
UpdateUser(ctx context.Context, userId string, metadata map[string]string) (usermanagement.User, error)
}
// NotifierClient is another contract.
//go:generate mockery --name=NotifierClient --output=./mocks --case=underscore
type NotifierClient interface {
Send(ctx context.Context, notification notifier.Notification) error
}
2. Implement the Logic ¶
Create your service implementation struct and constructor (/service/service.go).
type membershipServiceImpl struct {
client MembershipClient
notifier NotifierClient
}
// NewService accepts the contracts.
// In app.go, we will pass the "Big" client, which implicitly satisfies these interfaces.
func NewService(client MembershipClient, notifier NotifierClient) (MembershipService, error) {
return &membershipServiceImpl{
client: client,
notifier: notifier,
}, nil
}
Then write your logic in /service/yourservice.go.
func (s *membershipServiceImpl) RemoveMember(ctx context.Context, orgID, userID string) error {
// Business logic here...
}
Step 3: API Layer (Handler) ¶
Location: /internal/features/{feature}/api/
The API layer handles HTTP concerns: binding JSON, validation, and mapping errors to HTTP codes.
1. Define the Request DTO ¶
Use validator tags for input validation.
// internal/features/membership/models/types.go
type RemoveMemberRequest struct {
TargetUserId string `json:"target_user_id" validate:"required,uuid"`
}
2. Define the Service Contract ¶
Just like the Service layer, the API layer defines what it needs from the Service.
// internal/features/membership/api/removemember.go
// RemoveMemberService defines the contract for the business logic.
//
//go:generate mockery --name=RemoveMemberService --output=./mocks --case=underscore
type RemoveMemberService interface {
RemoveMember(ctx context.Context, orgID, targetUserID string) error
}
type RemoveMemberHandler struct {
service RemoveMemberService
validator *validator.Validate
}
3. Implement the Handler ¶
Standard flow: Bind -> Validate -> Service Call -> Error Handling.
func (h *RemoveMemberHandler) Handle(c *gin.Context) {
var req models.RemoveMemberRequest
// 1. Bind
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(httperror.NewStatusBadRequestError(httperror.ErrorConfig{Cause: err}))
return
}
// 2. Validate
if err := h.validator.Struct(req); err != nil {
c.Error(httperror.NewStatusUnprocessableEntityError(httperror.ErrorConfig{Cause: err}))
return
}
// 3. Call Service
// Note: We usually get the Caller's Org/User from the context middleware
authInfo, _ := requestmiddleware.GetWorkOSTokenInfo(c)
err := h.service.RemoveMember(c.Request.Context(), authInfo.OrgID, req.TargetUserId)
if err != nil {
// Log and return error
logger.ErrorAPI("Failed to remove member", err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
Step 4: Module Wiring ¶
Location: /internal/features/{feature}/module/module.go
The module package acts as the DI (Dependency Injection) container for a specific feature. It binds the Service implementation to the API handlers.
// Handlers groups all endpoints for this feature
type Handlers struct {
RemoveMember *membersapi.RemoveMemberHandler
// other handlers...
}
func NewHandlers(d Deps) (*Handlers, error) {
// ... checks for nil deps ...
// Inject the Service into the Handler.
// d.MembershipService (Concrete implementation) satisfies
// membersapi.RemoveMemberService (Interface)
removeHandler, err := membersapi.NewRemoveMemberHandler(membersapi.RemoveMemberDeps{
Service: d.MembershipService,
Validator: d.Validator,
})
return &Handlers{
RemoveMember: removeHandler,
}, nil
}
Step 5: Application Wiring & Routing ¶
Location: /internal/app/app.go
Finally, we wire everything together in the main application setup.
- Initialize the Service: Pass the "Big" Platform Clients. Go's implicit interface satisfaction handles the narrowing.
- Initialize the Module: Create the API handlers.
- Register the Route: Bind the handler to a URL path and middleware.
func setupFeatures(deps *AppDependencies) error {
// 1. Initialize Service (Service Layer)
// deps.WorkOSClient is the "Big" client.
// membershipService.NewService accepts it because it satisfies the smaller MembershipClient interface.
membershipSvc, err := membershipService.NewService(
deps.WorkOSClient,
deps.CloudflareNotifier,
deps.LivekitClient,
)
// 2. Initialize Module (API Layer)
membershipMod, err := membershipModule.NewHandlers(membershipModule.Deps{
MembershipService: membershipSvc,
Validator: deps.Validator,
})
// Store in deps for access in main.go or router setup
deps.MembershipApiHandler = membershipMod
return nil
}
In your Router setup (e.g., cmd/main.go or app.go routing section):
// Route Registration
memberGroup := v1.Group("/membership")
{
memberGroup.POST(
"/remove",
// Middleware for Permissions
requestmiddleware.RequirePermission(deps.RBAC, permissions.RemoveMember),
// The Handler
deps.MembershipApiHandler.RemoveMember.Handle,
)
}
Summary of the Data Flow ¶
- Request hits
POST /membership/remove. - Middleware verifies JWT and Permissions.
- API Handler (
membership/api) validates JSON input. - API Handler calls
service.RemoveMember(interface). - Service Implementation (
membership/service) executes logic. - Service calls
client.DeleteOrganizationMembership(interface). - Platform Wrapper (
platform/workos) executes the SDK call.
Why this overhead?¶
- Testing: We can test
apilogic by mocking theservice. We can testservicelogic by mocking theclient. - Stability: If WorkOS changes their SDK structure, we only change our
platformwrapper. The Service layer doesn't care as long as the contract is met. - Cognitive Load: When working on the
Membershipservice, you only see the 3 methods you rely on, not the 500 methods the actual LiveKit/WorkOS clients possess.