Payday Chat
A real-time, members-only network for online business founders.
The Problem
Founders needed a private, high-signal place to network, build teams, and scale revenue — not another noisy public feed. Payday Chat is invite-only and built around real-time collaboration.
My Role
Built solo with AI-assisted development — Flutter client, NestJS API, data model, real-time layer, and deployment.
Highlights
- Real-time messaging with presence and live events over WebSockets
- One NestJS API powering both a Flutter mobile app and a web client
- PostgreSQL domain model with Redis for caching and pub/sub fan-out
- NGINX gateway, Stripe payments, and OAuth authentication
Stack
Constraints
- Invite-only product — auth and access control had to be correct from day one.
- One backend, two clients (Flutter + web) — schema and contracts had to be shared, not duplicated.
- Realtime is a feature, not a nice-to-have — presence and live events must survive reconnects.
System Architecture
Key Trade-offs
The decisions worth defending — what I chose, what I turned down, and why.
Realtime transport
Chose
WebSockets with Redis pub/sub fan-out
Rejected
Long-polling or third-party realtime SaaS
Predictable latency, no per-message vendor cost, and Redis already in the stack for caching — one fewer moving part.
Mobile client framework
Chose
Flutter (single codebase for iOS + Android)
Rejected
Native Swift + Kotlin clients
Solo build — two native clients would have doubled the surface area and slowed iteration on the API.
API style
Chose
REST + dedicated WebSocket gateway
Rejected
GraphQL subscriptions
Simpler operational story, easier to cache at the NGINX layer, and the realtime channel stays an explicit, observable component.
What I'd Do Differently
An honest retrospective — the stuff I'd change with more time, more users, or a second pass.
- 1Introduce contract tests between the NestJS API and the Flutter client earlier — a few breakages were caught only at runtime.
- 2Move long-lived sockets to a dedicated process so API deploys don't drop client connections.
- 3Add structured event versioning from day one instead of retrofitting it once the schema started moving.
Technical Deep-Dive
Architecture, specifications, and implementation details.
New Module Checklist — Adding a NestJS Module
Follow this every time you add a new feature module to Payday.
#1. Generate Files
# Run inside the api container
docker compose exec api npx nest g module feature-name
docker compose exec api npx nest g controller feature-name
docker compose exec api npx nest g service feature-name
This creates:
src/feature-name/
├── feature-name.module.ts
├── feature-name.controller.ts
└── feature-name.service.ts
#2. Add Entity
Create src/feature-name/entities/feature-name.entity.ts:
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { UserEntity } from '../../members/entities/user.entity';
@Entity('feature_name') // snake_case table name
export class FeatureNameEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserEntity;
@Column({ length: 255 })
title: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
#3. Add DTOs
src/feature-name/dto/
├── create-feature-name.dto.ts
├── update-feature-name.dto.ts
└── query-feature-name.dto.ts
// create-feature-name.dto.ts
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateFeatureNameDto {
@ApiProperty({ example: 'My title' })
@IsString()
@IsNotEmpty()
@MaxLength(255)
title: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
}
#4. Wire up the Module
// feature-name.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([FeatureNameEntity]),
// Import other modules if needed
],
controllers: [FeatureNameController],
providers: [FeatureNameService],
exports: [FeatureNameService], // Export only if other modules need this service
})
export class FeatureNameModule {}
#5. Register in AppModule
// app.module.ts — add to imports array:
import { FeatureNameModule } from './feature-name/feature-name.module';
@Module({
imports: [
// ... existing modules ...
FeatureNameModule, // Add here
],
})
#6. Controller Boilerplate
@ApiTags('feature-name')
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Controller('feature-name')
export class FeatureNameController {
constructor(private readonly service: FeatureNameService) {}
@Post()
@ApiOperation({ summary: 'Create a feature' })
@ApiResponse({ status: 201, description: 'Created successfully' })
create(
@CurrentUserId() userId: string,
@Body() dto: CreateFeatureNameDto,
) {
return this.service.create(userId, dto);
}
@Get()
@ApiOperation({ summary: 'List features (paginated)' })
findAll(
@CurrentUserId() userId: string,
@Query() query: PaginationDto,
) {
return this.service.findAll(userId, query);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findOne(id);
}
@Patch(':id')
update(
@CurrentUserId() userId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateFeatureNameDto,
) {
return this.service.update(userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(
@CurrentUserId() userId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.service.remove(userId, id);
}
}
#7. Service Boilerplate
@Injectable()
export class FeatureNameService {
constructor(
@InjectRepository(FeatureNameEntity)
private readonly repo: Repository<FeatureNameEntity>,
) {}
async create(userId: string, dto: CreateFeatureNameDto) {
const entity = this.repo.create({ ...dto, userId });
return this.repo.save(entity);
}
async findAll(userId: string, { page = 1, limit = 20 }: PaginationDto) {
const [data, total] = await this.repo.findAndCount({
where: { userId },
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return paginate(data, total, page, limit);
}
async findOne(id: string) {
const entity = await this.repo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('Not found');
return entity;
}
async update(userId: string, id: string, dto: UpdateFeatureNameDto) {
const entity = await this.findOne(id);
if (entity.userId !== userId) throw new ForbiddenException('Not your resource');
Object.assign(entity, dto);
return this.repo.save(entity);
}
async remove(userId: string, id: string) {
const entity = await this.findOne(id);
if (entity.userId !== userId) throw new ForbiddenException('Not your resource');
await this.repo.softDelete(id);
}
}
#8. Checklist Before Committing
- Entity has
@Entity('table_name')with snake_case table name - All DTO fields have
class-validatordecorators +@ApiProperty - Controller has
@ApiTags,@ApiBearerAuth,@UseGuards(JwtAuthGuard) - All route params use
ParseUUIDPipefor UUID validation - Service never throws untyped errors — always
NotFoundException,ForbiddenException, etc. - Service ownership check before update/delete (compare
entity.userId !== userId) - Module imported in
AppModule - Module exported service if other modules need it
- Swagger docs load without errors:
http://localhost/docs