Skip to content

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:

  1. 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.
  2. 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).
  3. Testability: By generating mocks for these narrow interfaces, we can unit test every layer in isolation without spinning up actual external dependencies.

Workflow Summary

  1. Client Wrapper (Optional): Expose functionality from external SDKs if missing.
  2. Service Layer: Define the logic and the external dependencies (Contracts).
  3. API Layer: Define the HTTP handler, input validation, and Service dependency.
  4. Module Wiring: Wire the API handler to the Service implementation.
  5. 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.

  1. Initialize the Service: Pass the "Big" Platform Clients. Go's implicit interface satisfaction handles the narrowing.
  2. Initialize the Module: Create the API handlers.
  3. 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

  1. Request hits POST /membership/remove.
  2. Middleware verifies JWT and Permissions.
  3. API Handler (membership/api) validates JSON input.
  4. API Handler calls service.RemoveMember (interface).
  5. Service Implementation (membership/service) executes logic.
  6. Service calls client.DeleteOrganizationMembership (interface).
  7. Platform Wrapper (platform/workos) executes the SDK call.

Why this overhead?

  • Testing: We can test api logic by mocking the service. We can test service logic by mocking the client.
  • Stability: If WorkOS changes their SDK structure, we only change our platform wrapper. The Service layer doesn't care as long as the contract is met.
  • Cognitive Load: When working on the Membership service, you only see the 3 methods you rely on, not the 500 methods the actual LiveKit/WorkOS clients possess.