The eight lessons of this module travelled from the first connection to a database as far as validation at the schema level. The project is where all of it must work together - and where you will see for the first time that decisions made about the schema come back to you a week later, while writing queries.
You will build a tribute inventory system for a Roman legion: who owns what, where it came from, and how it passes from hand to hand.
Five areas the system must cover:
Do not begin with code. Begin with a sheet of paper and write down what relates to what - the one decision in this project whose later revision costs a great deal.
The legionary entity shows three things at once: column types, indexes and constraints at the database level:
1@Entity('legionaries')
2@Check('tribute_count_non_negative', '"tributeCount" >= 0')
3@Index(['name', 'isActive'])
4export class Legionary {
5 @PrimaryGeneratedColumn()
6 id: number;
7
8 @Column({ length: 100, unique: true })
9 name: string;
10
11 @Column({ length: 50 })
12 rank: string;
13
14 @Column({ type: 'int', default: 0 })
15 tributeCount: number;
16
17 @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
18 totalTributeValue: number;
19
20 @Column({ default: true })
21 isActive: boolean;
22
23 @CreateDateColumn()
24 joinedAt: Date;
25
26 @OneToMany(() => Tribute, (tribute) => tribute.owner)
27 tributes: Tribute[];
28
29 @ManyToOne(() => Cohort, (cohort) => cohort.legionaries)
30 cohort: Cohort;
31}Three decisions in this code are worth understanding, because you will repeat them in every project.
A tribute's value is a
, not a decimal
. A floating-point number cannot represent even 0.1 exactly; after a thousand transfers the totals will disagree by pennies, and where money is concerned that is the end of trust in the system. float
precision: 15, scale: 2 means fifteen significant digits, two of them after the point.
creates a composite index, useful when queries filter on both columns at once. Add indexes once you know what you will be asking - not "just in case", because each one slows writes down.@Index(['name', 'isActive'])
is the last line of defence, written into the database itself. Validation in a DTO protects you from a bad HTTP request, but not from a migration script, a manual @Check
UPDATE, or a bug in your own service. A constraint in the database holds regardless of who is writing.Choose relations by cardinality:
@OneToMany and @ManyToOne are two sides of the same relation (a legionary has many tributes, a tribute has one owner), @OneToOne suits a map tied to a single tribute, and @ManyToMany suits exchanges with several participants.Complex queries belong in a repository of your own:
1@Injectable()
2export class TributeRepository extends Repository<Tribute> {
3 async findValuableByLegionary(legionaryId: number, minValue: number) {
4 return this.createQueryBuilder('tribute')
5 .innerJoinAndSelect('tribute.owner', 'owner')
6 .where('owner.id = :legionaryId', { legionaryId })
7 .andWhere('tribute.value >= :minValue', { minValue })
8 .orderBy('tribute.value', 'DESC')
9 .getMany();
10 }
11}The division of responsibility is simple: the repository knows how to ask the database something; the service knows when and why. When a
createQueryBuilder appears in a service, it usually means the method has started doing two things at once.Note the
:legionaryId and :minValue parameters. This is not a matter of style - gluing a query together from strings opens the door to SQL injection. The Query Builder always passes values separately.Moving a tribute is the heart of this project, because it consists of operations that must all happen or none at all:
1async transferTribute(tributeId: number, fromId: number, toId: number) {
2 return this.dataSource.transaction(async (manager) => {
3 const tribute = await manager.findOne(Tribute, {
4 where: { id: tributeId, owner: { id: fromId } },
5 relations: ['owner'],
6 });
7
8 if (!tribute) {
9 throw new BadRequestException('Tribute not found or not owned by the sender');
10 }
11
12 tribute.owner = await manager.findOneOrFail(Legionary, { where: { id: toId } });
13 await manager.save(tribute);
14
15 await manager.decrement(Legionary, { id: fromId }, 'tributeCount', 1);
16 await manager.increment(Legionary, { id: toId }, 'tributeCount', 1);
17
18 return manager.save(Transaction, {
19 tribute,
20 fromLegionaryId: fromId,
21 toLegionaryId: toId,
22 });
23 });
24}Without a transaction, a failure halfway through leaves the system in an impossible state: the tribute has a new owner, but the old one's counter was never decremented - or worse, the counter fell and rose while the owner stayed the same. Such discrepancies surface months later, and by then there is no telling which records are true.
And now the greatest trap of the whole project. Inside a transaction use only
, never the injected repository. A call to manager
this.tributeRepository.save(...) in that block reaches for a different connection - one that knows nothing of the transaction. The code will compile, the tests will most likely pass, and on rollback that one operation will be written anyway. The rule is simple: if you are inside transaction(async (manager) => ...), then manager is your only road to the database.Seed data fills the database for development and testing:
1export class LegionarySeeder {
2 constructor(private dataSource: DataSource) {}
3
4 async run() {
5 const repo = this.dataSource.getRepository(Legionary);
6
7 const existing = await repo.count();
8 if (existing > 0) {
9 return;
10 }
11
12 await repo.save([
13 { name: 'Marcus Aurelius', rank: 'Centurion' },
14 { name: 'Gaius Julius', rank: 'Optio' },
15 ]);
16 }
17}One requirement separates a good seeder from a troublesome one: it must be runnable twice. A
count() check before writing, or an upsert, means a second call duplicates nothing. Without that, every environment restart multiplies the legionaries, and tests begin to depend on how many times somebody ran the seeder earlier.Order matters too: first the independent entities (legions, cohorts), then those that refer to them (legionaries), and last the tributes and transactions. The reverse order ends in a foreign key error.
The system has three places where data can be rejected, and each catches something different:
class-validator - the shape of the HTTP request. Here you reject a missing name or a negative value before anything touches the database.unique, nullable: false, @Check. It holds no matter which way the data arrived.Duplicating a rule across two levels is not a mistake - it is cheap insurance.
The project is finished when it meets six conditions:
synchronize: true.Before you submit, run one test: break a transfer halfway through - give a recipient that does not exist - and check both legionaries' counters in the database. If either changed, somewhere in your code an injected repository stands where
manager should be, @name.Send the link to your repository when you are done.