You are a Principal Go Engineer. Create a complete, compilable Go project for a Beauty Salon Client Management & Loyalty CRM following the specifications below. The project must use Domain-Driven Design, Hexagonal Architecture, and CQRS. Follow the reference patterns exactly.
The project follows these Go DDD infrastructure patterns. Use them in every aggregate, entity, value object, and event:
package ddd
type BaseEntity[ID comparable] struct { id ID }
func NewBaseEntity[ID comparable](id ID) *BaseEntity[ID] { return &BaseEntity[ID]{id: id} }
func (be *BaseEntity[ID]) ID() ID { return be.id }
func (be *BaseEntity[ID]) Equal(other *BaseEntity[ID]) bool { return other != nil && be.id == other.id }package ddd
type BaseAggregate[ID comparable] struct {
baseEntity *BaseEntity[ID]
domainEvents []DomainEvent
}
func NewBaseAggregate[ID comparable](id ID) *BaseAggregate[ID] {
return &BaseAggregate[ID]{baseEntity: NewBaseEntity[ID](id), domainEvents: make([]DomainEvent, 0)}
}
func (ba *BaseAggregate[ID]) ID() ID { return ba.baseEntity.ID() }
func (ba *BaseAggregate[ID]) RaiseDomainEvent(event DomainEvent) { ba.domainEvents = append(ba.domainEvents, event) }
func (ba *BaseAggregate[ID]) GetDomainEvents() []DomainEvent { return ba.domainEvents }
func (ba *BaseAggregate[ID]) ClearDomainEvents() { ba.domainEvents = []DomainEvent{} }type DomainEvent interface {
GetID() uuid.UUID
GetName() string
}type EventHandler interface { Handle(ctx context.Context, event DomainEvent) error }
type Mediatr interface {
Subscribe(handler EventHandler, events ...DomainEvent)
Publish(ctx context.Context, event DomainEvent) error
}Create exactly this structure:
salon-crm/
├── go.mod # module: salon-crm
├── go.sum
├── cmd/
│ ├── app/main.go
│ ├── composition_root.go
│ └── config.go
├── internal/
│ ├── pkg/
│ │ ├── ddd/
│ │ │ ├── entity.go # BaseEntity[ID]
│ │ │ ├── aggregate.go # BaseAggregate[ID]
│ │ │ ├── aggregate_root.go # AggregateRoot type alias
│ │ │ ├── domain_event.go # DomainEvent interface
│ │ │ └── mediatr.go # Mediatr implementation
│ │ ├── errs/
│ │ │ ├── value_required.go
│ │ │ └── value_must_be.go
│ │ └── outbox/
│ │ └── event_registry.go
│ ├── core/
│ │ ├── domain/
│ │ │ ├── model/
│ │ │ │ ├── money.go # Money value object (decimal, currency=RUB)
│ │ │ │ ├── phone_number.go # PhoneNumber VO with Russian format validation
│ │ │ │ ├── tenant_id.go # TenantID VO (shared kernel)
│ │ │ │ ├── birthday.go # Birthday VO
│ │ │ │ ├── discount.go # Discount VO (percent-based)
│ │ │ │ │
│ │ │ │ ├── client/
│ │ │ │ │ ├── client.go # Aggregate Root
│ │ │ │ │ ├── contact_info.go # VO: phone, email, firstName, lastName
│ │ │ │ │ ├── preferences.go # VO: preferredMasterID, favoriteServices, channel
│ │ │ │ │ ├── allergy.go # VO: substance, severity
│ │ │ │ │ ├── visit_record.go # Entity: appointmentID, masterID, service, price, review
│ │ │ │ │ ├── note.go # VO: text, authorID, createdAt
│ │ │ │ │ ├── photo.go # VO: url, type, uploadedAt
│ │ │ │ │ ├── client_source.go # enum: online_booking, admin_entry, referral, walk_in
│ │ │ │ │ ├── client_registered.go # Domain Event
│ │ │ │ │ └── client_test.go
│ │ │ │ │
│ │ │ │ ├── scheduling/
│ │ │ │ │ ├── appointment.go # Aggregate Root
│ │ │ │ │ ├── master_schedule.go # Aggregate Root
│ │ │ │ │ ├── time_slot.go # VO: startTime, endTime, OverlapsWith()
│ │ │ │ │ ├── service_info.go # VO: serviceID, name, duration, basePrice
│ │ │ │ │ ├── working_hours.go # VO: startTime, endTime, breakStart, breakEnd
│ │ │ │ │ ├── booking_source.go # enum: online, admin
│ │ │ │ │ ├── appointment_status.go # enum: Requested,Confirmed,InProgress,Completed,CancelledByClient,CancelledBySalon,NoShow
│ │ │ │ │ ├── appointment_booked.go # Domain Event
│ │ │ │ │ ├── appointment_completed.go
│ │ │ │ │ ├── appointment_cancelled.go
│ │ │ │ │ └── appointment_test.go
│ │ │ │ │
│ │ │ │ ├── loyalty/
│ │ │ │ │ ├── loyalty_account.go # Aggregate Root
│ │ │ │ │ ├── points.go # VO: int value, Add(), Subtract()
│ │ │ │ │ ├── tier.go # VO enum: Bronze, Silver, Gold, VIP
│ │ │ │ │ ├── tier_threshold.go # VO: tier + minLifetimePoints
│ │ │ │ │ ├── points_transaction.go # Entity: amount, type, reason, relatedEntityID
│ │ │ │ │ ├── referral.go # Entity: referredClientID, status, bonusEarned
│ │ │ │ │ ├── points_earned.go # Domain Event
│ │ │ │ │ ├── tier_changed.go # Domain Event
│ │ │ │ │ └── loyalty_test.go
│ │ │ │ │
│ │ │ │ └── certificate/
│ │ │ │ ├── certificate.go # Aggregate Root: balance, expiresAt, status
│ │ │ │ ├── certificate_activated.go
│ │ │ │ └── certificate_test.go
│ │ │ │
│ │ │ └── services/
│ │ │ ├── loyalty_policy.go # Domain Service
│ │ │ └── availability_service.go # Domain Service
│ │ │
│ │ ├── application/
│ │ │ ├── commands/
│ │ │ │ ├── register_client.go
│ │ │ │ ├── update_client_profile.go
│ │ │ │ ├── book_appointment.go
│ │ │ │ ├── cancel_appointment.go
│ │ │ │ ├── complete_appointment.go
│ │ │ │ ├── earn_points.go
│ │ │ │ └── activate_certificate.go
│ │ │ ├── queries/
│ │ │ │ ├── get_client.go
│ │ │ │ ├── get_client_history.go
│ │ │ │ ├── get_available_slots.go
│ │ │ │ └── get_loyalty_account.go
│ │ │ └── eventhandlers/
│ │ │ ├── accrue_points_on_completed.go
│ │ │ ├── add_visit_record_on_completed.go
│ │ │ ├── create_loyalty_on_registered.go
│ │ │ └── schedule_reminders_on_booked.go
│ │ │
│ │ └── ports/
│ │ ├── client_repository.go
│ │ ├── appointment_repository.go
│ │ ├── master_schedule_repository.go
│ │ ├── loyalty_repository.go
│ │ ├── certificate_repository.go
│ │ ├── notification_sender.go
│ │ ├── payment_client.go
│ │ ├── service_catalog_client.go
│ │ ├── outbox_repository.go
│ │ └── tx_manager.go
│ │
│ ├── adapters/
│ │ ├── in/http/
│ │ │ ├── client_handler.go
│ │ │ ├── appointment_handler.go
│ │ │ └── loyalty_handler.go
│ │ └── out/
│ │ ├── postgres/
│ │ │ ├── clientrepo/repository.go
│ │ │ ├── appointmentrepo/repository.go
│ │ │ ├── loyaltyrepo/repository.go
│ │ │ ├── schedulerepo/repository.go
│ │ │ └── tx_manager.go
│ │ └── inmemory/
│ │ ├── client_repository.go
│ │ └── appointment_repository.go
│ │
│ └── jobs/
│ └── outbox_job.go
├── migrations/
│ ├── 001_clients.sql
│ ├── 002_appointments.sql
│ ├── 003_loyalty.sql
│ └── 004_certificates.sql
├── configs/config.yaml
├── Dockerfile
└── makefile
| Subdomain | Type | Bounded Context |
|---|---|---|
| Client Management | Core | Client Context — profiles, contacts, preferences, allergies, photos, notes, visit history |
| Scheduling & Appointments | Core | Scheduling Context — booking, master schedules, time slots, availability, service catalog |
| Loyalty & Rewards | Core | Loyalty Context — points, tiers (Bronze/Silver/Gold/VIP), referrals, personal discounts |
| Subscriptions & Certificates | Supporting | Certificate Context — gift cards, subscriptions, activation, balance, expiration |
| Notifications | Supporting | Notification Context — SMS/WhatsApp/Email reminders, birthday, promos |
| Marketing | Supporting | Marketing Context — RFM segmentation, campaigns |
| Payments | Generic | Payment Context — ACL to Yandex.Kassa/Tinkoff/SBP |
| Analytics | Generic | Analytics Context — LTV, retention, avg check |
| Identity & Tenancy | Generic | Tenant Context — auth, multi-tenant (row-level via tenant_id) |
- Scheduling → Client: Customer-Supplier (AppointmentCompleted → VisitRecord)
- Scheduling → Loyalty: Customer-Supplier (AppointmentCompleted → EarnPoints)
- Scheduling → Notification: Published Language (AppointmentBooked → Reminders)
- Client → Marketing: Open Host Service (Client API for RFM queries)
- Scheduling → Payment: Anti-Corruption Layer (payment request abstraction)
- Tenant → All: Shared Kernel (TenantID value object)
- Loyalty → Client: Conformist (reads client data, conforms to Client model)
- Root:
Client— UUID id, TenantID, ContactInfo, Birthday, Preferences, []Allergy, []Note, []Photo, []VisitRecord, ClientSource, registeredAt - Entities:
VisitRecord(appointmentID, masterID, service, price, discount, paymentStatus, rating, review, visitedAt) - Value Objects: ContactInfo(phone, email, firstName, lastName), Preferences(preferredMasterID, favoriteServices, channel), Allergy(substance, severity), Note(text, authorID, createdAt), Photo(url, type, uploadedAt)
- Events:
ClientRegistered→ triggers loyalty account creation + welcome notification - Invariants: Phone required, valid format. Allergy deduplication by substance. Status guards.
- Methods:
NewClient(),UpdateProfile(),AddAllergy(),AddVisitRecord(),AddNote(),TotalVisits(),TotalSpent()
- Root:
Appointment— UUID id, TenantID, clientID, masterID, salonID, ServiceInfo, TimeSlot, status, price, BookingSource, comment - Value Objects: TimeSlot(startTime, endTime, Duration(), OverlapsWith()), ServiceInfo(serviceID, name, duration, basePrice), AppointmentStatus(enum)
- Events:
AppointmentBooked,AppointmentCompleted,AppointmentCancelledByClient - Invariants: Cannot book in the past. Must check master availability. Status transitions: Requested→Confirmed→InProgress→Completed. Cancel only if not InProgress/Completed.
- Methods:
NewAppointment(),Confirm(),Cancel(reason),Reschedule(newSlot),Complete(),NoShow()
- Root:
MasterSchedule— UUID id, masterID, salonID, date, WorkingHours, []bookedSlots, []blockedSlots - Value Objects: WorkingHours(startTime, endTime, breakStart, breakEnd)
- Invariants: Slots cannot overlap. Must be within working hours. Not during break.
- Methods:
IsAvailable(timeSlot),BookSlot(timeSlot),ReleaseSlot(timeSlot),GetAvailableSlots(duration)
- Root:
LoyaltyAccount— UUID id, clientID, TenantID, tier, Points balance, Points lifetimePoints, []PointsTransaction, []Referral - Entities: PointsTransaction(id, amount, type, reason, relatedEntityID, createdAt), Referral(id, referredClientID, status, bonusEarned, createdAt)
- Value Objects: Points(int value, Add, Subtract, IsZero), LoyaltyTier(enum: Bronze/Silver/Gold/VIP, DiscountPercent(), PointsMultiplier()), TierThreshold(tier, minPoints)
- Events:
LoyaltyPointsEarned,ClientTierChanged - Invariants: Cannot redeem more points than balance. Tier only changes upward. Referral bonus once per referred client.
- Methods:
EarnPoints(amount, reason),RedeemPoints(amount),RecalculateTier(),AddReferral(),GetPersonalDiscount()
| Tier | Min Lifetime Points | Discount % | Points Multiplier |
|---|---|---|---|
| Bronze | 0 | 0% | 1.0x |
| Silver | 5,000 | 5% | 1.2x |
| Gold | 15,000 | 10% | 1.5x |
| VIP | 50,000 | 15% | 2.0x |
- Fields: eventId, clientId, tenantId, firstName, lastName, phone, source, referredByClientId
- Consumers: Loyalty (create account), Notification (welcome), Marketing (add to segment)
- Fields: eventId, appointmentId, clientId, masterId, salonId, serviceId, serviceName, startTime, endTime, price, source
- Consumers: Notification (schedule 24h + 2h reminders)
- Fields: eventId, appointmentId, clientId, masterId, salonId, serviceName, finalPrice, discount, paymentMethod
- Consumers: Loyalty (accrue points), Client (add visit record), Notification (request review)
- Fields: eventId, loyaltyAccountId, clientId, pointsEarned, multiplier, reason, relatedEntityId, newBalance, lifetimePoints
- Consumers: Tier recalculation check
- Fields: eventId, loyaltyAccountId, clientId, previousTier, newTier, lifetimePoints, newDiscountPercent
- Consumers: Notification (congratulations), Client profile update
- Fields: eventId, certificateId, activatedByClientId, purchasedByClientId, balance, expiresAt
- Consumers: Payment (available balance)
- Fields: eventId, appointmentId, clientId, masterId, salonId, originalStartTime, cancelledAt, reason
- Consumers: Schedule (release slot), Notification (notify master)
Interface: LoyaltyPolicy
- CalculatePointsForVisit(amount Money, tier LoyaltyTier) → Points // 1pt per 10 RUB × tier multiplier
- DetermineNewTier(lifetimePoints Points) → LoyaltyTier // highest tier where threshold ≤ points
- GetReferralBonus() → Points // 500 points
- GetPersonalDiscount(tier LoyaltyTier) → Discount // tier-based %
Interface: AvailabilityService
- GetAvailableSlots(masterID, salonID, date, serviceDuration) → []TimeSlot
- IsSlotAvailable(masterID, date, timeSlot) → bool
ClientRepository:
Add(ctx, tx, *Client) error
Update(ctx, tx, *Client) error
Get(ctx, tx, id UUID) (*Client, error)
FindByPhone(ctx, tx, tenantID, phone) (*Client, error)
FindByTenant(ctx, tx, tenantID, limit, offset) ([]*Client, error)
AppointmentRepository:
Add(ctx, tx, *Appointment) error
Update(ctx, tx, *Appointment) error
Get(ctx, tx, id UUID) (*Appointment, error)
FindByClientID(ctx, tx, clientID) ([]*Appointment, error)
FindByMasterAndDate(ctx, tx, masterID, date) ([]*Appointment, error)
MasterScheduleRepository:
Add(ctx, tx, *MasterSchedule) error
Update(ctx, tx, *MasterSchedule) error
GetByMasterAndDate(ctx, tx, masterID, date) (*MasterSchedule, error)
LoyaltyRepository:
Add(ctx, tx, *LoyaltyAccount) error
Update(ctx, tx, *LoyaltyAccount) error
GetByClientID(ctx, tx, clientID) (*LoyaltyAccount, error)
TxManager:
Execute(ctx, func(tx Tx) error) error
- Validate startTime is in the future
- Get service details (duration, price) from ServiceCatalog
- Build TimeSlot from startTime + duration
- Inside transaction:
a. Load MasterSchedule for master+date (with lock)
b. Check
schedule.IsAvailable(timeSlot)→ error if not c.schedule.BookSlot(timeSlot)to reserve d. CreateNewAppointment(...)aggregate e. Persist appointment + updated schedule - Outbox publishes
AppointmentBookeddomain event
- Check no existing client with same phone+tenantID
- Create
NewClient(tenantID, contactInfo, source) - Persist → raises
ClientRegisteredevent - Event handler creates LoyaltyAccount + sends welcome notification
- Load appointment by ID
- Call
appointment.Complete() - Persist → raises
AppointmentCompleted - EventHandler: accrue loyalty points, add visit record to client
- Subscribes to:
AppointmentCompleted - Loads LoyaltyAccount by clientID
- Calls
loyaltyPolicy.CalculatePointsForVisit(finalPrice, account.Tier()) - Calls
account.EarnPoints(points, "appointment_completed") - Calls
account.RecalculateTier()usingloyaltyPolicy.DetermineNewTier() - Persists LoyaltyAccount
- Subscribes to:
ClientRegistered - Creates new
LoyaltyAccountwith Bronze tier, 0 points - If referredByClientId present: adds Referral, earns bonus for both
- Subscribes to:
AppointmentCompleted - Creates VisitRecord from event data
- Loads Client, calls
client.AddVisitRecord(record), persists
- Subscribes to:
AppointmentBooked - Schedules notification: 24h before startTime
- Schedules notification: 2h before startTime
- CQRS: Yes. Separate command/query handlers. Write side = rich domain model; read side = direct DB queries.
- Event Sourcing: No. PostgreSQL CRUD with domain events via Outbox pattern.
- Database: PostgreSQL. JSONB for preferences, notes. Row-level multi-tenancy via
tenant_id. - Event Bus: Outbox table → cron job → Mediatr (in-process). Kafka for cross-service if needed later.
- API: REST (oapi-codegen from OpenAPI specs). WebSocket for real-time calendar updates.
- Multi-tenant:
tenant_idcolumn on every table. TenantID value object shared across all contexts (Shared Kernel). - Dependencies: github.com/google/uuid, github.com/shopspring/decimal, gorm.io/gorm, github.com/labstack/echo/v4.
CREATE TABLE clients (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
phone VARCHAR(20) NOT NULL,
email VARCHAR(255),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100),
birthday DATE,
preferences JSONB DEFAULT '{}',
allergies JSONB DEFAULT '[]',
notes JSONB DEFAULT '[]',
source VARCHAR(50) NOT NULL,
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, phone)
);
CREATE INDEX idx_clients_tenant ON clients(tenant_id);
CREATE INDEX idx_clients_phone ON clients(tenant_id, phone);CREATE TABLE appointments (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
client_id UUID NOT NULL REFERENCES clients(id),
master_id UUID NOT NULL,
salon_id UUID NOT NULL,
service_id UUID NOT NULL,
service_name VARCHAR(200) NOT NULL,
service_duration INTERVAL NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'requested',
price_amount DECIMAL(12,2) NOT NULL,
price_currency VARCHAR(3) DEFAULT 'RUB',
source VARCHAR(20) NOT NULL,
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE master_schedules (
id UUID PRIMARY KEY,
master_id UUID NOT NULL,
salon_id UUID NOT NULL,
schedule_date DATE NOT NULL,
work_start TIME NOT NULL,
work_end TIME NOT NULL,
break_start TIME,
break_end TIME,
booked_slots JSONB DEFAULT '[]',
blocked_slots JSONB DEFAULT '[]',
UNIQUE(master_id, schedule_date)
);CREATE TABLE loyalty_accounts (
id UUID PRIMARY KEY,
client_id UUID NOT NULL UNIQUE REFERENCES clients(id),
tenant_id UUID NOT NULL,
tier VARCHAR(10) NOT NULL DEFAULT 'Bronze',
balance INT NOT NULL DEFAULT 0,
lifetime_points INT NOT NULL DEFAULT 0
);
CREATE TABLE points_transactions (
id UUID PRIMARY KEY,
loyalty_account_id UUID NOT NULL REFERENCES loyalty_accounts(id),
amount INT NOT NULL,
type VARCHAR(20) NOT NULL,
reason VARCHAR(100),
related_entity_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE referrals (
id UUID PRIMARY KEY,
loyalty_account_id UUID NOT NULL REFERENCES loyalty_accounts(id),
referred_client_id UUID NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
bonus_earned INT DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);CREATE TABLE certificates (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
purchased_by UUID REFERENCES clients(id),
activated_by UUID REFERENCES clients(id),
balance_amount DECIMAL(12,2) NOT NULL,
balance_currency VARCHAR(3) DEFAULT 'RUB',
status VARCHAR(20) NOT NULL DEFAULT 'created',
activated_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);CREATE TABLE outbox (
id UUID PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);- Create all files following the folder structure exactly.
- Every value object must validate in its constructor (
New*returns error) and have aMust*panic variant. - Every aggregate root embeds
*ddd.BaseAggregate[uuid.UUID]and delegates ID/events to it. - Every aggregate has a
Restore*function for rehydration from the database (no validation, no events). - All struct fields are unexported; provide getter methods.
- Business rules are enforced in aggregate methods, not in application/adapter layer.
- Write unit tests for every aggregate covering: happy path, invariant violations, event raising.
- Repository interfaces go in
ports/. Implementations go inadapters/out/. - Command handlers use
TxManager.Execute()for transactional boundaries. - The Outbox pattern: TxManager saves domain events to outbox table within the same transaction. A cron job reads pending events and publishes them via Mediatr.
- In-memory repository implementations for testing.
composition_root.gowires everything together using lazy initialization withsync.Once.