DEV Community

Cover image for A State Machine in 10 Lines: Event Status Transitions in NestJS
Rhico
Rhico

Posted on

A State Machine in 10 Lines: Event Status Transitions in NestJS

I'm building RunHop in public — a social + event platform for running races, built on NestJS.

Today I built the Event module: full CRUD, cursor-based pagination, and a status state machine that controls an event's lifecycle. The state machine was the interesting part.

The Problem

Events in RunHop go through four stages: DRAFT → PUBLISHED → CLOSED → COMPLETED. Not every transition is valid — you can't jump from DRAFT to CLOSED, and you can't go backwards from COMPLETED. There's also one special case: PUBLISHED → DRAFT is allowed, but only if nobody has registered yet.

I've seen state machines implemented with dedicated libraries, switch statements spanning hundreds of lines, or class hierarchies with a State interface. For four states and a handful of transitions, all of that is overkill.

The Implementation

The entire transition map:

// event.service.ts
const VALID_TRANSITIONS: Record<string, string[]> = {
    DRAFT: ['PUBLISHED'],
    PUBLISHED: ['DRAFT', 'CLOSED'],
    CLOSED: ['COMPLETED'],
    COMPLETED: []
};
Enter fullscreen mode Exit fullscreen mode

Five lines. Each status maps to an array of valid next states. COMPLETED maps to an empty array — it's a terminal state.

The updateStatus() method:

async updateStatus(id: string, dto: UpdateEventStatusDto) {
    const event = await this.findById(id);

    const validTransitions = VALID_TRANSITIONS[event.status.toUpperCase()];
    if (!validTransitions.includes(dto.status.toUpperCase())) {
        throw new BadRequestException(
            `Cannot transition from ${event.status} to ${dto.status}`
        );
    }

    // Special case: PUBLISHED → DRAFT only if no confirmed registrations
    if (event.status === 'PUBLISHED' && dto.status === 'DRAFT') {
        const registration = await this.prisma.registration.count({
            where: {
                race: { eventId: id },
                status: 'CONFIRMED'
            }
        });

        if (registration > 0) {
            throw new BadRequestException(
                'Cannot revert to DRAFT — event has confirmed registrations',
            );
        }
    }

    return this.prisma.event.update({
        where: { id },
        data: { status: dto.status }
    });
}
Enter fullscreen mode Exit fullscreen mode

Look up the current status. Check if the requested status is in the allowed list. If not, throw. If it's the special PUBLISHED → DRAFT case, check registrations. Then update.

Adding a new status or transition later is a one-line change to the map.

The Date Format Gap

The other interesting problem today was a mismatch between validation and storage.

The CreateEventDto uses @IsDateString() from class-validator to validate date inputs:

@IsDateString()
startDate!: string;
Enter fullscreen mode Exit fullscreen mode

@IsDateString() accepts any valid ISO-8601 string — including date-only formats like 2026-06-01. But Prisma's DateTime type requires the full format: 2026-06-01T00:00:00.000Z. So a request with startDate: "2026-06-01" passes validation, enters the service, and then Prisma throws:

Invalid value for argument startDate: premature end of input.
Expected ISO-8601 DateTime.

There's a gap between "valid input" and "what the ORM accepts." The fix is a @Transform decorator from class-transformer that coerces the value after validation:

@IsDateString()
@Transform(({ value }) => new Date(value).toISOString())
startDate!: string;
Enter fullscreen mode Exit fullscreen mode

Now the API accepts both 2026-06-01 and 2026-06-01T00:00:00.000Z. The transform runs after validation (important — you don't want to transform invalid input), and by the time the value reaches the service, it's always in the full format Prisma expects.

One thing to make sure: your ValidationPipe has transform: true enabled, otherwise @Transform decorators don't run:

app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
Enter fullscreen mode Exit fullscreen mode

Cross-Field Validation

While building the DTOs, I needed endDate to be after startDate. class-validator doesn't have a built-in cross-field comparison, so I wrote a custom decorator:

// src/common/validators/is-after.validator.ts
export function IsAfter(property: string, validationOptions?: ValidationOptions) {
    return function (object: Object, propertyName: string) {
        registerDecorator({
            name: 'isAfter',
            target: object.constructor,
            propertyName,
            constraints: [property],
            options: validationOptions,
            validator: {
                validate(value: any, args: ValidationArguments) {
                    const [relatedPropertyName] = args.constraints;
                    const relatedValue = (args.object as any)[relatedPropertyName];
                    return new Date(value) > new Date(relatedValue);
                }
            }
        });
    };
}
Enter fullscreen mode Exit fullscreen mode

Usage in the DTO:

@IsDateString()
@Transform(({ value }) => new Date(value).toISOString())
@IsAfter('startDate', { message: 'end date must be after start date' })
endDate!: string;
Enter fullscreen mode Exit fullscreen mode

registerDecorator is the class-validator API for custom validation rules. The constraints array holds the name of the field to compare against, and args.object gives access to the full DTO instance so you can read the other field's value.

Extracting Shared Logic

The organization module had a generateSlug() method that turned names into URL-friendly slugs. The event module needs the same thing. Rather than duplicate it, I extracted it into GenerateSlugService in src/common/:

@Injectable()
export class GenerateSlugService {
    generateSlug(name: string): string {
        const res = name
            .toLowerCase()
            .replace(/\s+/g, '-')
            .replace(/[^a-z0-9-]/g, '')
        return `${res}-${randomUUID().slice(0, 8)}`;
    }
}
Enter fullscreen mode Exit fullscreen mode

The UUID suffix prevents collisions without needing a retry loop or a uniqueness check query. manila-runners-club-a3f8b2c1 is unique enough for a slug.

Both OrganizationService and EventService now inject GenerateSlugService instead of each having their own copy.

E2E vs Unit: Know Which Assertion Style You're In

One mistake I caught during testing: using .rejects.toThrow(BadRequestException) in an e2e test. In a unit test, where you're calling service.updateStatus() directly, exceptions propagate and you can catch them with rejects.toThrow(). In an e2e test, supertest sends an HTTP request. NestJS exception filters catch the BadRequestException and return a 400 response. Supertest always resolves — it never rejects.

Unit test (calling service directly):

await expect(service.updateStatus('id', { status: 'CLOSED' }))
    .rejects.toThrow(BadRequestException);
E2E test (HTTP via supertest):

const result = await request(app.getHttpServer())
    .patch(`/api/v1/events/${event.id}/status`)
    .send({ status: 'CLOSED' });

expect(result.statusCode).toBe(400);
Enter fullscreen mode Exit fullscreen mode

Different layers, different assertion patterns.

Takeaway

A state machine doesn't need a library. If your transitions fit in a Record<string, string[]>, that's the right abstraction. The map is readable, testable, and extensible. The special cases (like the registration check for PUBLISHED → DRAFT) live in the method that uses the map — not in the map itself. Keep the data structure simple and put the logic where it belongs.

Top comments (0)