Event Ticketing & Booking System -- Clean Architecture & DDD
Proyek ini dikembangkan untuk EF2344-02 Konstruksi Perangkat Lunak -- Departemen Teknik Informatika, ITS .
Topik: Case Study -- Event Ticketing & Booking System
Arsitektur: Clean Architecture + Domain-Driven Design Tactical Patterns
Paradigma: Functional Programming (ES8 arrow functions, factory functions, no classes)
Stack: Bun + ElysiaJS + TypeScript
Progress Week 8: Project Structure
Clean Architecture folder structure (domain/, application/, database/, infrastructure/, presentation/)
Initial business rules derived from acceptance criteria
Ubiquitous Language Glossary (docs/ubiquitous-language-glossary.md)
ElysiaJS app setup with Swagger docs + health check endpoint
Progress Week 9-10: Domain Layer & Unit Tests
1. Aggregates -- domain/aggregates/
File
State Lifecycle
Domain Events Raised
event.ts
Draft -> Published -> Cancelled / Completed
EventCreated, EventPublished, EventCancelled, TicketCategoryCreated, TicketCategoryDisabled
booking.ts
Pending -> Paid / Cancelled / Expired / Refunded
BookingCreated, TicketReserved, BookingPaid, BookingCancelled, BookingExpired
ticket.ts
Active -> CheckedIn / Refunded / Cancelled
TicketCheckedIn
refund.ts
Requested -> Approved / Rejected -> PaidOut
RefundRequested, RefundApproved, RefundRejected, RefundPaidOut
2. Entities -- domain/entities/
File
Extends
Description
entity.ts
--
Base Entity interface + equalsEntity() function
ticket-category.ts
Entity
Child entity of Event (status: active, comingSoon, salesClosed, soldOut)
user.ts
Entity
Standalone entity (roles: organizer, customer, admin)
promo-code.ts
Entity
Standalone entity with validation, usage, deactivation logic
3. Value Objects -- domain/value-objects/
File
Invariant
email.ts
Email format validation
money.ts
Amount >= 0, currency default IDR
date-range.ts
End >= Start
ticket-code.ts
Min 8 chars, generated via nanoid
4. Domain Services -- domain/domain-services/
File
Business Logic
booking.service.ts
Calculate total price + validate booking request
ticket-availability.service.ts
Check ticket availability per category
5. Domain Events -- domain/domain-events/
15 domain events:
Event
Trigger
EventCreated
Event created
EventPublished
Event published
EventCancelled
Event cancelled
TicketCategoryCreated
Ticket category added
TicketCategoryDisabled
Ticket category disabled
BookingCreated
Booking created
BookingPaid
Booking paid
BookingCancelled
Booking cancelled
BookingExpired
Booking expired
TicketReserved
Tickets reserved
TicketCheckedIn
Ticket checked in
RefundRequested
Refund requested
RefundApproved
Refund approved
RefundRejected
Refund rejected
RefundPaidOut
Refund paid out
6. Repository Interfaces -- domain/repository-interfaces/
Interface
Methods
EventRepository
findById, findAll, findByOrganizer, save, update, delete
BookingRepository
findById, findByCustomer, findByEvent, save, update
TicketRepository
findById, findByBooking, findByEvent, findByCustomer, findByCode, save, update
UserRepository
findById, findByEmail, save, update
RefundRepository
findById, findByBooking, save, update
PromoCodeRepository
findById, findByCode, findAllActive, save, update
7. Domain Unit Tests -- domain/tests/
76 tests -- 0 fail
File
Tests
Coverage
user-stories.test.ts
68
All 12 minimum test cases + user stories + domain event assertions + PromoCode/TicketCategory logic
entities.test.ts
2
User entity equality
value-objects.test.ts
6
Email, Money, DateRange, TicketCode
12 Minimum Test Cases (AGENT.md SS5):
#
Test Case
Status
1
Event cannot be created if end date < start date
Pass
2
Event cannot be created with capacity <= 0
Pass
3
Event cannot be published without active ticket category
Pass
4
Ticket category quota cannot exceed event capacity
Pass
5
Booking cannot be created with quantity 0
Pass
6
Booking cannot be paid after payment deadline
Pass
7
Booking cannot be paid with incorrect amount
Pass
8
Paid booking cannot expire
Pass
9
Checked-in ticket cannot be checked in again
Pass
10
Refund cannot be requested if ticket is checked in
Pass
11
Refund cannot be approved if not in Requested status
Pass
12
Rejected refund must have a rejection reason
Pass
8. User Story Implementation Map
#
User Story
Command / Query
Domain Logic
Domain Event
Test
1
Create Event
createEventHandler (.commands/event.handlers.ts:19)
createEvent() (.aggregates/event.ts:48)
EventCreated (.aggregates/event.ts:62)
.tests/user-stories.test.ts:119
2
Publish Event
publishEventHandler (.commands/event.handlers.ts:43)
publishEvent() (.aggregates/event.ts:94)
EventPublished (.aggregates/event.ts:103)
.tests/user-stories.test.ts:149
3
Cancel Event
cancelEventHandler (.commands/event.handlers.ts:53)
cancelEvent() (.aggregates/event.ts:107)
EventCancelled (.aggregates/event.ts:113)
.tests/user-stories.test.ts:185
4
Create Ticket Category
addTicketCategoryHandler (.commands/event.handlers.ts:63)
addCategory() (.aggregates/event.ts:120)
TicketCategoryCreated (.aggregates/event.ts:142)
.tests/user-stories.test.ts:204
5
Disable Ticket Category
disableTicketCategoryHandler (.commands/event.handlers.ts:84)
disableCategory() (.aggregates/event.ts:144)
TicketCategoryDisabled (.aggregates/event.ts:149)
.tests/user-stories.test.ts:269
6
View Available Events
getAvailableEventsHandler (.queries/event.handlers.ts:96)
—
—
.tests/application.test.ts:316
7
View Event Details
getEventDetailsHandler (.queries/event.handlers.ts:89)
—
—
.tests/application.test.ts:329
8
Create Ticket Booking
createBookingHandler (.commands/booking.handlers.ts:24)
createBooking() (.aggregates/booking.ts:46)
BookingCreated (.aggregates/booking.ts:71), TicketReserved (.aggregates/booking.ts:73)
.tests/user-stories.test.ts:284
9
Calculate Booking Total Price
inline di createBookingHandler
getSubtotal() (.aggregates/booking.ts:83)
—
.tests/user-stories.test.ts:374
10
Pay Booking
payBookingHandler (.commands/booking.handlers.ts:78)
payBooking() (.aggregates/booking.ts:87)
BookingPaid (.aggregates/booking.ts:96)
.tests/user-stories.test.ts:406
11
Expire Booking
expireBookingHandler (.commands/booking.handlers.ts:148)
expireBooking() (.aggregates/booking.ts:105)
BookingExpired (.aggregates/booking.ts:111)
.tests/user-stories.test.ts:441
12
View Purchased Tickets
listTicketsByCustomerHandler (.queries/ticket.handlers.ts:60)
—
—
.tests/application.test.ts:476
13
Check In Ticket
checkInTicketHandler (.commands/ticket.handlers.ts:5)
checkInTicket() (.aggregates/ticket.ts:50)
TicketCheckedIn (.aggregates/ticket.ts:55)
.tests/user-stories.test.ts:462
14
Reject Invalid Ticket Check-in
—
error: wrong event (.aggregates/ticket.ts:52), not active (.aggregates/ticket.ts:53)
—
.tests/user-stories.test.ts:479
15
Request Refund
requestRefundHandler (.commands/refund.handlers.ts:22)
createRefund() (.aggregates/refund.ts:28)
RefundRequested (.aggregates/refund.ts:44)
.tests/user-stories.test.ts:502
16
Approve Refund
approveRefundHandler (.commands/refund.handlers.ts:54)
approveRefund() (.aggregates/refund.ts:55)
RefundApproved (.aggregates/refund.ts:60)
.tests/user-stories.test.ts:546
17
Reject Refund
rejectRefundHandler (.commands/refund.handlers.ts:83)
rejectRefund() (.aggregates/refund.ts:63)
RefundRejected (.aggregates/refund.ts:70)
.tests/user-stories.test.ts:573
18
Mark Refund as Paid Out
payoutRefundHandler (.commands/refund.handlers.ts:94)
payoutRefund() (.aggregates/refund.ts:77)
RefundPaidOut (.aggregates/refund.ts:83)
.tests/user-stories.test.ts:591
19
View Event Sales Report
getEventSalesReportHandler (.queries/event.handlers.ts:119)
—
—
.tests/application.test.ts:340
20
View Event Participants
getEventParticipantsHandler (.queries/event.handlers.ts:160)
—
—
.tests/application.test.ts:373
Semua path di atas relatif terhadap src/app/domain/ atau src/app/application/.
8a. Acceptance Criteria → Code Reference
US1: Create Event (src/app/domain/aggregates/event.ts)
AC
Implementasi
Event created with title, description, dates, location, capacity
event.ts:48-63 → createEvent() factory
End date cannot be before start date
event.ts:54 → if (endDate < startDate) throw
Capacity must be > 0
event.ts:56 → if (maxCapacity <= 0) throw
Status = Draft
event.ts:48 → parameter status: "draft"
Domain event EventCreated raised
event.ts:62 → addDomainEvent(event, createEventCreated(...))
US2: Publish Event (src/app/domain/aggregates/event.ts)
AC
Implementasi
Must have at least one active category
event.ts:97-99 → categories.filter(c ⇒ c.isActive).length === 0
Total quota ≤ max capacity
event.ts:100-101 → getTotalQuota(event) > maxCapacity
Draft → Published
event.ts:96 validasi, event.ts:102 status change
Cancelled cannot be published
event.ts:96 → status !== "draft"
Domain event EventPublished raised
event.ts:103
US3: Cancel Event (src/app/domain/aggregates/event.ts)
AC
Implementasi
Published can be cancelled
event.ts:108-114
Completed cannot be cancelled
event.ts:109 → "Completed events cannot be cancelled"
Domain event EventCancelled raised
event.ts:113
US4: Create Ticket Category (src/app/domain/aggregates/event.ts + entities/ticket-category.ts)
AC
Implementasi
Category has name, price, quota, sales period
entities/ticket-category.ts:6-13
Price ≥ 0
entities/ticket-category.ts:26 → "Ticket price cannot be negative"
Quota > 0
entities/ticket-category.ts:27 → "Ticket quota must be greater than zero"
Sales period ends ≤ event start
event.ts:129 → "Sales period must end before or at the event start"
Total quota ≤ capacity
event.ts:140 → "Total ticket quota cannot exceed event capacity"
Domain event TicketCategoryCreated raised
event.ts:142
US5: Disable Ticket Category (src/app/domain/aggregates/event.ts)
AC
Implementasi
Can disable if not completed
event.ts:145 → "Cannot disable category on completed event"
Historical data preserved
event.ts:148 → deactivateCategory(category) (hanya set flag)
Cannot purchase inactive
event.ts:166 → "Ticket category is not active"
Domain event TicketCategoryDisabled raised
event.ts:149
US6: View Available Events (src/app/application/queries/event.handlers.ts)
AC
Implementasi
Only published events shown
event.handlers.ts:99 → filter(e ⇒ e.status === "published")
Cancelled not displayed
same filter
Filter by date/location
event.handlers.ts:101-108
US7: View Event Details (src/app/application/queries/event.handlers.ts)
AC
Implementasi
Display name, description, date, location, categories
event.handlers.ts:89-94 → getEventDetailsHandler
Sales status (ComingSoon/SalesClosed/SoldOut)
src/app/domain/entities/ticket-category.ts:33-40 → getSalesStatus()
US8: Create Ticket Booking (src/app/domain/aggregates/booking.ts)
AC
Implementasi
Only for published events
booking.ts:53 → "Booking can only be created for published events"
Items must have quantity > 0
booking.ts:55-58
Payment deadline > creation time
booking.ts:61
Status = Pending
booking.ts:65 → parameter "pending"
Domain event TicketReserved raised
booking.ts:73-74
US9: Calculate Booking Total Price (src/app/domain/aggregates/booking.ts)
AC
Implementasi
Price = unitPrice × quantity
booking.ts:83-85 → getSubtotal()
Service fee included
booking.ts:66 → parameter serviceFee
Money value object
src/app/domain/value-objects/money.ts
US10: Pay Booking (src/app/domain/aggregates/booking.ts + application/commands/booking.handlers.ts)
AC
Implementasi
Must be Pending
booking.ts:89 → "Only pending bookings can be paid"
Cannot pay past deadline
booking.ts:91 → "Payment deadline has passed"
Amount must match total
booking.ts:93 → "Payment amount must equal total booking price"
Status → Paid
booking.ts:95
Tickets issued with unique codes
booking.handlers.ts:99-117 → generateTicketCode()
US11: Expire Booking (src/app/domain/aggregates/booking.ts + commands/booking.handlers.ts)
AC
Implementasi
Pending → Expired
booking.ts:106-110
Paid cannot expire
booking.ts:108 → "Only pending bookings can expire"
Quota released
booking.handlers.ts:159-165 → releaseCategory()
Domain event BookingExpired raised
booking.ts:111
US12: View Purchased Tickets (src/app/application/queries/ticket.handlers.ts)
AC
Implementasi
Query by customer email
ticket.handlers.ts:60-66 → listTicketsByCustomerHandler
Unique ticket code
src/app/domain/value-objects/ticket-code.ts:7-10 → generateTicketCode()
US13: Check In Ticket (src/app/domain/aggregates/ticket.ts)
AC
Implementasi
Must match event
ticket.ts:52 → "Ticket does not match the event"
Must be Active
ticket.ts:53 → "Ticket is not active"
Already checked-in rejected
ticket.ts:53
Status → CheckedIn
ticket.ts:54
Domain event TicketCheckedIn raised
ticket.ts:55
US14: Reject Invalid Ticket Check-in (src/app/domain/aggregates/ticket.ts)
AC
Implementasi
Wrong event → error
ticket.ts:52
Already used → error
ticket.ts:53
Status unchanged (throw before mutation)
ticket.ts:52-53 (throw duluan)
US15: Request Refund (src/app/application/commands/refund.handlers.ts + domain/aggregates/refund.ts)
AC
Implementasi
Must be Paid booking
refund.handlers.ts:30 → "Only paid bookings can be refunded"
No checked-in tickets
refund.handlers.ts:35 → "Cannot refund booking with checked-in tickets"
Status: Requested / Approved / Rejected / PaidOut
refund.ts:7-8
Domain event RefundRequested raised
refund.ts:44
US16: Approve Refund (src/app/domain/aggregates/refund.ts + commands/refund.handlers.ts)
AC
Implementasi
Must be Requested
refund.ts:57 → "Only requested refunds can be approved"
Status → Approved
refund.ts:58
Tickets → Cancelled
refund.handlers.ts:74-79
Booking → Refunded
refund.handlers.ts:68-71 → markBookingRefunded()
Domain event RefundApproved raised
refund.ts:60
US17: Reject Refund (src/app/domain/aggregates/refund.ts + commands/refund.handlers.ts)
AC
Implementasi
Must be Requested
refund.ts:65 → "Only requested refunds can be rejected"
Rejection reason required
refund.ts:66 → parameter reason
Status → Rejected
refund.ts:67
Booking remains Paid
refund.handlers.ts:84-91 → tidak sentuh booking
Domain event RefundRejected raised
refund.ts:70
US18: Mark Refund as Paid Out (src/app/domain/aggregates/refund.ts)
AC
Implementasi
Must be Approved
refund.ts:80 → "Only approved refunds can be paid out"
Payment reference recorded
refund.ts:82 → paymentReference
Status → PaidOut
refund.ts:81
Cannot be re-modified (terminal state)
refund.ts:80 → hanya Approved → PaidOut
Domain event RefundPaidOut raised
refund.ts:83
US19: View Event Sales Report (src/app/application/queries/event.handlers.ts)
AC
Implementasi
Tickets sold per category
event.handlers.ts:132-142 → categoryReports[]
Total revenue
event.handlers.ts:144-145 → sum(revenue)
US20: View Event Participants (src/app/application/queries/event.handlers.ts)
AC
Implementasi
Participants from Paid bookings
event.handlers.ts:160-184 → getEventParticipantsHandler
Data: name, category, code, check-in status
event.handlers.ts:171-178 → ParticipantDTO
Retrieved via application query
event.handlers.ts:160
9. Domain Flows by User Story
Input: title, description, startDate, endDate, location, maxCapacity
│
▼
[src/app/domain/aggregates/event.ts] createEvent()
│
├── Validasi: endDate >= startDate, maxCapacity > 0
├── Status: "draft"
└── Raise: EventCreated
Input: event (status "draft")
│
▼
[src/app/domain/aggregates/event.ts] publishEvent()
│
├── Validasi: minimal 1 kategori aktif
├── Validasi: totalQuota <= maxCapacity
├── Status: "published"
└── Raise: EventPublished
Input: event (status "draft" | "published")
│
▼
[src/app/domain/aggregates/event.ts] cancelEvent()
│
├── Validasi: bukan "completed" atau "cancelled"
├── Status: "cancelled"
└── Raise: EventCancelled
US4: Create Ticket Category (child entity)
Input: event, name, price, quota, salesStart, salesEnd
│
▼
[src/app/domain/aggregates/event.ts] addCategory()
│
├── Validasi: event masih "draft" | "published"
├── Validasi: salesEnd <= event.startDate
├── Validasi: quota + currentTotal <= maxCapacity
├── [src/app/domain/entities/ticket-category.ts] createTicketCategory()
└── Raise: TicketCategoryCreated
US5: Disable Ticket Category
Input: event, categoryId
│
▼
[src/app/domain/aggregates/event.ts] disableCategory()
│
├── Validasi: event belum "completed"
├── [src/app/domain/entities/ticket-category.ts] deactivateCategory()
└── Raise: TicketCategoryDisabled
US8: Create Ticket Booking
Input: eventId, customerId, items[{categoryId, qty}], totalAmount, serviceFee
│
▼
[src/app/domain/aggregates/booking.ts] createBooking()
│
├── Validasi: items.length > 0
├── Validasi: setiap item.quantity > 0
├── Validasi: paymentDeadline > createdAt
├── Status: "pending"
├── Raise: BookingCreated, TicketReserved
└── [event.ts] reserveCategory() → [src/app/domain/entities/ticket-category.ts] reserveCategoryQuota()
US9: Calculate Booking Total Price
Input: booking items
│
▼
[src/app/domain/aggregates/booking.ts] getSubtotal()
│
└── Sum(item.unitPrice × item.quantity) + serviceFee
Input: booking (status "pending"), amount
│
▼
[src/app/domain/aggregates/booking.ts] payBooking()
│
├── Validasi: status === "pending"
├── Validasi: now <= paymentDeadline
├── Validasi: amount === totalAmount
├── Status: "paid", paidAt: now
└── Raise: BookingPaid
Input: booking (status "pending")
│
▼
[src/app/domain/aggregates/booking.ts] expireBooking()
│
├── Validasi: status === "pending"
├── Status: "expired"
└── Raise: BookingExpired
Input: ticket (status "active"), eventId
│
▼
[src/app/domain/aggregates/ticket.ts] checkInTicket()
│
├── Validasi: ticket.eventId === eventId
├── Validasi: status === "active"
├── Status: "checkedIn", checkedInAt: now
└── Raise: TicketCheckedIn
US14: Reject Invalid Ticket Check-in
Input: ticket (status "checkedIn" | "refunded" | "cancelled"), eventId
│
▼
[src/app/domain/aggregates/ticket.ts] checkInTicket()
│
├── ❌ Validasi gagal: ticket.eventId !== eventId → throw "Ticket does not match the event"
└── ❌ Validasi gagal: status !== "active" → throw "Ticket is not active"
Input: bookingId, amount, reason
│
▼
[src/app/domain/aggregates/refund.ts] createRefund()
│
├── [booking.ts] markBookingRefunded() → validasi status "paid"
├── [ticket.ts] markTicketRefunded() → validasi status "active"
├── Status: "requested"
└── Raise: RefundRequested
Input: refund (status "requested")
│
▼
[src/app/domain/aggregates/refund.ts] approveRefund()
│
├── Validasi: status === "requested"
├── Status: "approved", resolvedAt: now
└── Raise: RefundApproved
Input: refund (status "requested"), reason
│
▼
[src/app/domain/aggregates/refund.ts] rejectRefund()
│
├── Validasi: status === "requested"
├── Status: "rejected", resolvedAt: now, rejectionReason: reason
└── Raise: RefundRejected
US18: Mark Refund as Paid Out
Input: refund (status "approved"), paymentReference
│
▼
[src/app/domain/aggregates/refund.ts] payoutRefund()
│
├── Validasi: status === "approved"
├── Status: "paidOut", paymentReference: ref
└── Raise: RefundPaidOut
Progress Week 11: Application Layer
1. Commands -- application/commands/
File
Command Types
Handler
event.commands.ts
CreateEventCommand, PublishEventCommand, CancelEventCommand, AddTicketCategoryCommand, DisableTicketCategoryCommand
event.handlers.ts: createEventHandler, publishEventHandler, cancelEventHandler, addTicketCategoryHandler, disableTicketCategoryHandler
booking.commands.ts
CreateBookingCommand, PayBookingCommand, CancelBookingCommand, ExpireBookingCommand
booking.handlers.ts: createBookingHandler, payBookingHandler, cancelBookingHandler, expireBookingHandler
ticket.commands.ts
CheckInTicketCommand
ticket.handlers.ts: checkInTicketHandler
refund.commands.ts
RequestRefundCommand, ApproveRefundCommand, RejectRefundCommand, PayoutRefundCommand
refund.handlers.ts: requestRefundHandler, approveRefundHandler, rejectRefundHandler, payoutRefundHandler
Setiap handler memanggil fungsi domain (createEvent, payBooking, dll) dan menyimpan via Repository.
2. Queries -- application/queries/
File
Query Types
Handler
event.queries.ts
GetEventQuery, ListEventsQuery, GetEventDetailsQuery, GetAvailableEventsQuery, GetEventSalesReportQuery, GetEventParticipantsQuery
event.handlers.ts: mapping domain → EventDTO, SalesReportDTO, EventParticipantsDTO
booking.queries.ts
GetBookingQuery, ListBookingsByCustomerQuery, ListBookingsByEventQuery
booking.handlers.ts: mapping domain → BookingDTO
ticket.queries.ts
GetTicketQuery, GetTicketByCodeQuery, ListTicketsByBookingQuery, ListTicketsByEventQuery, ListTicketsByCustomerQuery
ticket.handlers.ts: mapping domain → TicketDTO
refund.queries.ts
GetRefundQuery, ListRefundsByBookingQuery
refund.handlers.ts: mapping domain → RefundDTO
3. DTOs -- application/dtos/
File
DTOs
event.dto.ts
EventDTO, TicketCategoryDTO, CreateEventDTO, AddTicketCategoryDTO, ParticipantDTO, EventParticipantsDTO, SalesReportCategoryDTO, SalesReportDTO
booking.dto.ts
BookingDTO, BookingItemDTO, CreateBookingDTO, PayBookingDTO, ListBookingsQueryDTO, BookingSummaryDTO
ticket.dto.ts
TicketDTO, CheckInTicketDTO
refund.dto.ts
RefundDTO, RequestRefundDTO, ApproveRefundDTO, RejectRefundDTO, PayoutRefundDTO
4. Application Service Interfaces -- application/services/
Interface
Methods
PaymentGateway
processPayment, refundPayment
NotificationService
sendBookingConfirmation, sendTicketDetails, sendPaymentConfirmation, sendRefundNotification
RefundPaymentService
processRefund
5. Application Flow per User Story
[CreateEventCommand]
title, description, startDate, endDate, location, maxCapacity, organizerId
│
▼
[event.handlers.ts] createEventHandler()
│
├── nanoid() → eventId
├── createEvent(eventId, ...) → domain
│ └── domain: validasi endDate >= startDate, maxCapacity > 0
└── eventRepository.save(event)
[PublishEventCommand] { eventId }
│
▼
[event.handlers.ts] publishEventHandler()
│
├── eventRepository.findById(eventId)
├── publishEvent(event) → domain
│ └── domain: validasi minimal 1 kategori aktif, quota <= capacity
└── eventRepository.update(event)
[CancelEventCommand] { eventId }
│
▼
[event.handlers.ts] cancelEventHandler()
│
├── eventRepository.findById(eventId)
├── cancelEvent(event) → domain
└── eventRepository.update(event)
US4: Create Ticket Category
[AddTicketCategoryCommand] { eventId, name, price, quota, salesStart, salesEnd }
│
▼
[event.handlers.ts] addTicketCategoryHandler()
│
├── eventRepository.findById(eventId)
├── nanoid() → categoryId
├── createMoney(price) → value object
├── addCategory(event, categoryId, ...) → domain
└── eventRepository.update(event)
US5: Disable Ticket Category
[DisableTicketCategoryCommand] { eventId, categoryId }
│
▼
[event.handlers.ts] disableTicketCategoryHandler()
│
├── eventRepository.findById(eventId)
├── disableCategory(event, categoryId) → domain
└── eventRepository.update(event)
US6: View Available Events
[GetAvailableEventsQuery] { location?, startDate?, endDate?, limit?, offset? }
│
▼
[event.handlers.ts] getAvailableEventsHandler()
│
├── eventRepository.findAll()
├── filter: status === "published"
├── filter opsional: location, startDate, endDate
├── pagination: offset + limit
└── map to EventDTO[]
[GetEventDetailsQuery] { eventId }
│
▼
[event.handlers.ts] getEventDetailsHandler()
│
├── eventRepository.findById(eventId)
└── map to EventDTO (dengan kategori + salesStatus)
US8: Create Ticket Booking
[CreateBookingCommand] { eventId, customerId, items[{categoryId, quantity}] }
│
▼
[booking.handlers.ts] createBookingHandler()
│
├── eventRepository.findById(eventId)
├── reserveCategory(event, categoryId, quantity) → domain (kurangi quota)
├── hitung subtotal per item
├── nanoid() → bookingId
├── createBooking(...) → domain
│ └── domain: validasi items.length > 0, quantity > 0
├── bookingRepository.save(booking)
└── eventRepository.update(event) → simpan quota terbaru
US9: Calculate Booking Total Price
Proses di createBookingHandler():
items.map(item → unitPrice = category.price.amount)
subtotal = sum(unitPrice × quantity)
totalAmount = subtotal + serviceFee
createBooking(..., totalAmount, serviceFee, ...)
[PayBookingCommand] { bookingId, amount, paymentReference }
│
▼
[booking.handlers.ts] payBookingHandler()
│
├── bookingRepository.findById(bookingId)
├── paymentGateway.processPayment(bookingId, amount, paymentReference)
├── payBooking(booking, amount) → domain
│ └── domain: validasi pending, deadline, amount
├── bookingRepository.update(booking)
├── generate ticket untuk setiap item × quantity
│ └── createTicket() + ticketRepository.save()
└── notificationService.sendBookingConfirmation()
[ExpireBookingCommand] { bookingId }
│
▼
[booking.handlers.ts] expireBookingHandler()
│
├── bookingRepository.findById(bookingId)
├── expireBooking(booking) → domain
├── bookingRepository.update(booking)
├── eventRepository.findById(booking.eventId)
└── releaseCategory(event, categoryId, quantity) → domain (kembalikan quota)
US12: View Purchased Tickets
[ListTicketsByCustomerQuery] { customerEmail }
│
▼
[ticket.handlers.ts] listTicketsByCustomerHandler()
│
├── ticketRepository.findByCustomer(customerEmail)
└── map to TicketDTO[]
[CheckInTicketCommand] { ticketCode, eventId }
│
▼
[ticket.handlers.ts] checkInTicketHandler()
│
├── ticketRepository.findByCode(ticketCode)
├── checkInTicket(ticket, eventId) → domain
│ └── domain: validasi eventId cocok, status === "active"
└── ticketRepository.update(ticket)
US14: Reject Invalid Ticket Check-in
(checkInTicketHandler → domain checkInTicket)
│
├── ❌ ticket.eventId !== eventId → throw "Ticket does not match the event"
└── ❌ ticket.status !== "active" → throw "Ticket is not active"
[RequestRefundCommand] { bookingId, reason }
│
▼
[refund.handlers.ts] requestRefundHandler()
│
├── bookingRepository.findById(bookingId)
├── validasi: booking.status === "paid"
├── ticketRepository.findByBooking(bookingId)
├── validasi: tidak ada ticket dengan status "checkedIn"
├── nanoid() → refundId
├── createRefund(...) → domain
└── refundRepository.save(refund)
[ApproveRefundCommand] { refundId }
│
▼
[refund.handlers.ts] approveRefundHandler()
│
├── refundRepository.findById(refundId)
├── approveRefund(refund) → domain
├── refundRepository.update(refund)
├── bookingRepository.findById(refund.bookingId)
├── markBookingRefunded(booking)
├── bookingRepository.update(booking)
├── ticketRepository.findByBooking(refund.bookingId)
└── untuk setiap ticket active → markTicketRefunded(ticket) + update
[RejectRefundCommand] { refundId, reason }
│
▼
[refund.handlers.ts] rejectRefundHandler()
│
├── refundRepository.findById(refundId)
├── rejectRefund(refund, reason) → domain
└── refundRepository.update(refund)
US18: Mark Refund as Paid Out
[PayoutRefundCommand] { refundId, paymentReference }
│
▼
[refund.handlers.ts] payoutRefundHandler()
│
├── refundRepository.findById(refundId)
├── refundPaymentService.processRefund(refundId, amount, paymentReference)
├── payoutRefund(refund, paymentReference) → domain
└── refundRepository.update(refund)
US19: View Event Sales Report
[GetEventSalesReportQuery] { eventId }
│
▼
[event.handlers.ts] getEventSalesReportHandler()
│
├── eventRepository.findById(eventId)
├── ticketRepository.findByEvent(eventId)
├── hitung sold per kategori
├── totalSold = sum(sold), totalRevenue = sum(revenue)
└── map to SalesReportDTO
US20: View Event Participants
[GetEventParticipantsQuery] { eventId }
│
▼
[event.handlers.ts] getEventParticipantsHandler()
│
├── ticketRepository.findByEvent(eventId)
├── hitung checkedInCount
└── map to EventParticipantsDTO (daftar partisipan + status)
6. Application Layer Tests -- application/tests/
29 tests -- 0 fail
Test Group
Tests
Coverage
Event Command Handlers
5
create, publish, cancel, addCategory, disableCategory
Booking Command Handlers
1
createBooking
Ticket Command Handlers
1
checkInTicket
Refund Command Handlers
1
requestRefund
Event Query Handlers
6
getById, list filters, available events, details, sales report, participants
Booking Query Handlers
2
getById, listByCustomer
Ticket Query Handlers
2
getByCode, listByCustomer
Refund Query Handlers
2
getById, listByBooking
DTO Mappings
4
Event, Booking, Ticket, Refund DTO structure
Service Mocks
3
PaymentGateway, NotificationService, RefundPaymentService
Total: 126 tests (71 domain + 29 application + 12 infrastructure + 14 presentation) -- 0 fail
# Run all tests
bun run test
# Run domain tests only
bun run test:domain
# Run application tests
bun test src/app/application/tests/
# Run specific file
bun test src/app/domain/tests/user-stories.test.ts
Progress Week 12: Infrastructure Layer
1. Database Schema -- database/drizzle/schema/schema.ts
8 PostgreSQL tables + 6 enums menggunakan Drizzle ORM:
Table
Columns
users
id, name, email, role (enum: organizer/customer/admin), createdAt
events
id, title, description, startDate, endDate, location, maxCapacity, status (enum: draft/published/cancelled/completed), organizerId (FK → users), createdAt, updatedAt
ticket_categories
id, eventId (FK → events), name, priceAmount, priceCurrency, quota, remaining, salesStart, salesEnd, isActive
bookings
id, eventId (FK → events), customerId (FK → users), totalAmount, serviceFee, status (enum: pending/paid/cancelled/expired/refunded), createdAt, paidAt, paymentDeadline
booking_items
id, bookingId (FK → bookings), ticketCategoryId, categoryName, quantity, unitPrice
tickets
id, bookingId (FK → bookings), eventId (FK → events), categoryName, code (unique), priceAmount, priceCurrency, status (enum: active/checkedIn/refunded/cancelled), customerName, customerEmail, createdAt, checkedInAt
refunds
id, bookingId (FK → bookings), amount, currency, reason, status (enum: requested/approved/rejected/paidOut), requestedAt, resolvedAt, rejectionReason, paymentReference
promo_codes
id, code (unique), discountType (enum: percentage/fixed), discountValue, maxUsage, usedCount, minPurchaseAmount, minPurchaseCurrency, validPeriodStart, validPeriodEnd, isActive
2. Database Connection -- database/drizzle/index/connection.ts
getDatabase ( ) → Drizzle instance ( PostgreSQL via postgres . js )
Menggunakan Supabase connection pooling (port 6543) via env DATABASE_URL
Singleton pattern: koneksi dibuat sekali lalu di-cache
3. Relations -- database/drizzle/relations/relations.ts
Relasi Drizzle ORM:
Event → TicketCategories (one-to-many)
Booking → BookingItems (one-to-many)
Booking → Tickets (one-to-many)
Booking → Refunds (one-to-many)
User → Events (one-to-many, sebagai organizer)
User → Bookings (one-to-many, sebagai customer)
4. Repository Implementations -- infrastructure/repositories/
Repository
File
Methods
Notes
EventRepository
event.repository.ts
findById, findAll, findByOrganizer, save, update, delete
Map domain ↔ DB (Money, TicketCategory)
BookingRepository
booking.repository.ts
findById, findByCustomer, findByEvent, save, update
item serialization
TicketRepository
ticket.repository.ts
findById, findByBooking, findByEvent, findByCustomer, findByCode, save, update
ticket code lookup
RefundRepository
refund.repository.ts
findById, findByBooking, save, update
full refund state
Semua repository adalah factory functions : createEventRepository(db) → EventRepository.
5. Application Service Implementations -- infrastructure/services/
Service
File
Methods
PaymentGateway
payment-gateway.service.ts
processPayment, refundPayment
NotificationService
notification.service.ts
sendBookingConfirmation, sendTicketDetails, sendPaymentConfirmation, sendRefundNotification
RefundPaymentService
refund-payment.service.ts
processRefund
Catatan: Implementasi saat ini menggunakan console.log (stub). Untuk production, integrasikan dengan payment gateway (Midtrans/Xendit) dan notification service (SendGrid/WhatsApp API) yang sesungguhnya.
6. Seed Data -- database/drizzle/seeds/seed.ts
import { getDatabase } from "../index/connection" ;
import { seedDatabase } from "./seeds/seed" ;
const db = getDatabase ( ) ;
await seedDatabase ( db ) ;
Men-generate: 2 user, 2 event, 3 kategori tiket, 1 booking, 2 tiket, 1 promo code.
7. Infrastructure Tests -- infrastructure/tests/
12 tests -- 0 fail
Test Group
Tests
Schema exports
2 (tables + enums)
Connection
1 (getDatabase export)
Relations
1 (all relation exports)
Event Repository
1 (interface methods)
Booking Repository
1 (interface methods)
Ticket Repository
1 (interface methods)
Refund Repository
1 (interface methods)
Payment Gateway
1 (interface methods)
Notification Service
1 (interface methods)
Refund Payment Service
1 (interface methods)
Seed Script
1 (export check)
Progress Week 13: Presentation Layer
1. REST API Controllers -- presentation/controllers/
Event Controller (event.controller.ts) — US1-US7, US19-US20:
Method
Path
User Story
Body/Params
POST
/events
US1: Create Event
{ title, description, startDate, endDate, location, maxCapacity, organizerId }
POST
/events/:eventId/publish
US2: Publish Event
—
POST
/events/:eventId/cancel
US3: Cancel Event
—
POST
/events/:eventId/categories
US4: Create Ticket Category
{ name, price, quota, salesStart, salesEnd }
PATCH
/events/:eventId/categories/:categoryId/disable
US5: Disable Ticket Category
—
GET
/events
US6: View Available Events
?location=&startDate=&endDate=
GET
/events/:eventId
US7: View Event Details
—
GET
/events/:eventId/sales-report
US19: Sales Report
—
GET
/events/:eventId/participants
US20: Participants
—
Booking Controller (booking.controller.ts) — US8-US11:
Method
Path
User Story
Body/Params
POST
/bookings
US8: Create Booking
{ eventId, customerId, items[{ ticketCategoryId, quantity }] }
GET
/bookings/:bookingId
US9: Booking Total
—
POST
/bookings/:bookingId/pay
US10: Pay Booking
{ amount, paymentReference }
POST
/bookings/:bookingId/expire
US11: Expire Booking
—
GET
/bookings/customer/:customerId
—
?status=
GET
/bookings/event/:eventId
—
?status=
Ticket Controller (ticket.controller.ts) — US12-US14:
Method
Path
User Story
Body/Params
GET
/tickets/customer/:email
US12: View Purchased Tickets
—
GET
/tickets/booking/:bookingId
—
—
GET
/tickets/validate/:code
US14: Validate Ticket
—
POST
/tickets/check-in
US13: Check In Ticket
{ ticketCode, eventId }
Refund Controller (refund.controller.ts) — US15-US18:
Method
Path
User Story
Body/Params
POST
/refunds
US15: Request Refund
{ bookingId, reason }
POST
/refunds/:refundId/approve
US16: Approve Refund
—
POST
/refunds/:refundId/reject
US17: Reject Refund
{ reason }
POST
/refunds/:refundId/payout
US18: Payout Refund
{ paymentReference }
GET
/refunds/booking/:bookingId
—
—
GET
/refunds/:refundId
—
—
createApp(deps?) di create-app.ts menyediakan dependency injection opsional:
// Default: in-memory repositories (development / testing)
const app = createApp ( ) ;
// Production: inject Supabase repositories + services
const app = createApp ( {
eventRepository : drizzleEventRepo ,
bookingRepository : drizzleBookingRepo ,
ticketRepository : drizzleTicketRepo ,
refundRepository : drizzleRefundRepo ,
paymentGateway : realPaymentGateway ,
notificationService : realNotificationService ,
refundPaymentService : realRefundPaymentService ,
} ) ;
In-memory repository factories juga di-export untuk testing:
createInMemoryEventRepository()
createInMemoryBookingRepository()
createInMemoryTicketRepository()
createInMemoryRefundRepository()
Global onError handler:
VALIDATION errors → { error: "Validation error", details: "<message>" }
Domain errors → { error: "<domain error message>" }
Swagger UI tersedia di GET /docs (via @elysiajs/swagger).
5. Presentation Tests -- presentation/tests/
14 tests -- 0 fail
Test
Coverage
Controller exports
4 (Event, Booking, Ticket, Refund)
createApp
1 (app creation with default config)
Health check
1 (GET /health)
Create Event
1 (POST /events)
Publish Event
1 (POST /events/:id/publish with category)
List Events
1 (GET /events)
Event Details
1 (GET /events/:id)
Create Booking
1 (POST /bookings with published event)
Get Booking
1 (GET /bookings/:id)
Validate Ticket
1 (GET /tickets/validate/:code)
Check-in
1 (POST /tickets/check-in invalid)
# Run all 126 tests
bun run test
# Run domain tests only
bun run test:domain
# Run application tests
bun test src/app/application/tests/
# Run infrastructure tests
bun test src/app/infrastructure/tests/
# Run presentation tests
bun test src/app/presentation/tests/
# Run specific file
bun test src/app/domain/tests/user-stories.test.ts
Buat project di Supabase
Salin connection string dari Project Settings → Database → Connection String (URI)
Format: postgresql://postgres.[PROJECT_REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
Set environment variable:
# Windows PowerShell
$env :DATABASE_URL = " postgresql://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres"
# atau via .env file
Generate migration:
bunx drizzle-kit generate
Run migration:
Seed database:
bun run src/app/database/drizzle/seeds/seed.ts
Runtime: Bun
Framework: ElysiaJS
Language: TypeScript
Database: PostgreSQL via Supabase
ORM: Drizzle
Architecture: Clean Architecture + DDD Tactical Patterns
Paradigm: Functional Programming