Stage 7 · Master
Phase 2 — Organization Service
Service Layer
Write the business rules — slug conflicts, status transitions — as the only code in this service allowed to make a decision, and finally give pkg/apperr something real to translate.
Business Rules Live Here, Not in Handlers or Repositories
internal/service/organization_service.go is the only package in this service permitted to decide whether an action is allowed. The repository knows how to store and retrieve rows; the handler, written next, knows how to speak HTTP. Neither is allowed to know that an archived organization can never be reactivated — that rule, and every rule like it, lives exactly once, here.
Trusting the Database Constraint Over Check-Then-Insert
Create does not call GetBySlug first to check availability before inserting — doing so would reopen exactly the race condition the Repository Layer lesson's unique-constraint handling was built to close. Instead, Create always attempts the insert and inspects the error: repository.ErrDuplicateSlug means the database itself rejected a duplicate, atomically, regardless of how many concurrent requests raced to create the same slug at once.
A Status Transition Table as Code, Not Scattered ifs
package service
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/hoa-platform/backend/pkg/apperr"
"github.com/hoa-platform/backend/services/organization/internal/dto"
"github.com/hoa-platform/backend/services/organization/internal/model"
"github.com/hoa-platform/backend/services/organization/internal/repository"
)
// allowedTransitions is the organization lifecycle's transition table,
// expressed as data instead of as a chain of if-statements scattered
// across Suspend and Archive. archived intentionally maps to an empty
// slice — it is a terminal state.
var allowedTransitions = map[model.Status][]model.Status{
model.StatusActive: {model.StatusSuspended, model.StatusArchived},
model.StatusSuspended: {model.StatusActive, model.StatusArchived},
model.StatusArchived: {},
}
type OrganizationService struct {
repo Repository
}
func NewOrganizationService(repo Repository) *OrganizationService {
return &OrganizationService{repo: repo}
}
func (s *OrganizationService) Create(ctx context.Context, req dto.CreateOrganizationRequest) (*model.Organization, error) {
org := &model.Organization{
Name: req.Name,
Slug: req.Slug,
RegistrationNumber: req.RegistrationNumber,
ContactEmail: req.ContactEmail,
ContactPhone: req.ContactPhone,
AddressLine1: req.AddressLine1,
AddressLine2: req.AddressLine2,
City: req.City,
State: req.State,
PostalCode: req.PostalCode,
Country: req.Country,
Status: model.StatusActive,
}
if err := s.repo.Create(ctx, org); err != nil {
if errors.Is(err, repository.ErrDuplicateSlug) {
return nil, apperr.Conflict(fmt.Sprintf("an organization with slug %q already exists", req.Slug))
}
return nil, apperr.Internal(err, "failed to create organization")
}
return org, nil
}
func (s *OrganizationService) GetByID(ctx context.Context, id uuid.UUID) (*model.Organization, error) {
org, err := s.repo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, apperr.NotFound("organization not found")
}
return nil, apperr.Internal(err, "failed to load organization")
}
return org, nil
}
func (s *OrganizationService) List(ctx context.Context, filter repository.ListFilter) ([]model.Organization, int, error) {
orgs, total, err := s.repo.List(ctx, filter)
if err != nil {
return nil, 0, apperr.Internal(err, "failed to list organizations")
}
return orgs, total, nil
}
func (s *OrganizationService) Update(ctx context.Context, id uuid.UUID, req dto.UpdateOrganizationRequest) (*model.Organization, error) {
org, err := s.GetByID(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
org.Name = *req.Name
}
if req.ContactEmail != nil {
org.ContactEmail = *req.ContactEmail
}
if req.ContactPhone != nil {
org.ContactPhone = *req.ContactPhone
}
if req.AddressLine1 != nil {
org.AddressLine1 = *req.AddressLine1
}
if req.AddressLine2 != nil {
org.AddressLine2 = *req.AddressLine2
}
if err := s.repo.Update(ctx, org); err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, apperr.NotFound("organization not found")
}
return nil, apperr.Internal(err, "failed to update organization")
}
return org, nil
}
func (s *OrganizationService) transition(ctx context.Context, id uuid.UUID, to model.Status) (*model.Organization, error) {
org, err := s.GetByID(ctx, id)
if err != nil {
return nil, err
}
allowed := false
for _, candidate := range allowedTransitions[org.Status] {
if candidate == to {
allowed = true
break
}
}
if !allowed {
return nil, apperr.Conflict(fmt.Sprintf("cannot move organization from %q to %q", org.Status, to))
}
org.Status = to
if err := s.repo.Update(ctx, org); err != nil {
return nil, apperr.Internal(err, "failed to update organization status")
}
return org, nil
}
func (s *OrganizationService) Suspend(ctx context.Context, id uuid.UUID) (*model.Organization, error) {
return s.transition(ctx, id, model.StatusSuspended)
}
func (s *OrganizationService) Archive(ctx context.Context, id uuid.UUID) (*model.Organization, error) {
return s.transition(ctx, id, model.StatusArchived)
}
Where pkg/apperr Finally Gets Used for Real
Phase 1's Error Handling lesson built apperr.Conflict, apperr.NotFound, and apperr.Internal with no caller. This is that caller. Every apperr constructor used above carries exactly the semantic meaning the Handler Layer lesson (next) will need to pick a status code from — the service layer decides what happened, and deliberately leaves deciding the HTTP status to the layer that actually speaks HTTP.
Two administrators submitting 'create organization' with the same slug within the same second is a realistic HOA support scenario — perhaps a franchise operator retrying after a slow network response. Because Create relies on the database's atomic unique constraint rather than a preceding availability check, exactly one request succeeds and the other receives a clean 409-shaped apperr.Conflict, regardless of timing.
A Lightweight Diagnostic, Not the Full Suite Yet
The Unit Testing lesson later builds a complete fake-backed test suite for this file. For now, one small, fast test confirms the transition table itself is internally complete — every status has an entry, even if that entry is an empty slice — which is the kind of data-shape bug that is easy to introduce silently while editing a map literal.
package service
import "testing"
func TestAllowedTransitions_CoversEveryStatus(t *testing.T) {
for _, status := range []struct{ name string }{{"active"}, {"suspended"}, {"archived"}} {
found := false
for s := range allowedTransitions {
if string(s) == status.name {
found = true
break
}
}
if !found {
t.Errorf("allowedTransitions is missing an entry for status %q", status.name)
}
}
}
Applied exercise
Add a fourth status and prove the transition table catches the omission
Confirm TestAllowedTransitions_CoversEveryStatus is actually doing useful work, not just passing trivially.
- Temporarily add a fourth constant, model.StatusPendingApproval, to internal/model/organization.go's Status type (do not add it to Valid()'s switch yet).
- Update this lesson's test to also check for "pending_approval" and confirm it now fails, since allowedTransitions has no entry for it.
- Add an entry for it to allowedTransitions (decide the transitions yourself — perhaps only to active or archived) and confirm the test passes again.
- Revert both the model.go and organization_service_test.go changes — StatusPendingApproval is not part of this course's real schema.
Deliverable
A short note showing the test failing after adding the new status and passing after fixing allowedTransitions, followed by a full revert.
Completion checks
- The test fails specifically citing the missing status name before the fix.
- Both files are reverted to their lesson state afterward — git diff shows no leftover changes.
Why does OrganizationService.Create not call GetBySlug to check for an existing slug before inserting?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.