Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 9 additions & 13 deletions src/Billing/Transaction/Application/Confirm/Handler.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import { Application, EventSourcing } from "hollywood-js";
import { Application } from "hollywood-js";
import type { IAppError } from "hollywood-js/src/Application/Bus/CallbackArg";
import { inject, injectable } from "inversify";
import NotFoundException from "../../../Shared/Domain/Exceptions/NotFoundException";
import Transaction from "../../Domain/Transaction";
import ITransactionWriteRepository from "../../Domain/WriteRepository";
import ConfirmCommand from "./Command";

@injectable()
export default class Confirm implements Application.ICommandHandler {
constructor(
@inject("infrastructure.transaction.eventStore")
private readonly writeModel: EventSourcing.EventStore<Transaction>,
@inject("infrastructure.transaction.writeRepository")
private readonly writeModel: ITransactionWriteRepository,
) {}

@Application.autowiring
public async handle(command: ConfirmCommand): Promise<void | IAppError> {
try {
const transaction = await this.writeModel.load(command.uuid.toIdentity()) as Transaction;
transaction.confirm();
await this.writeModel.save(transaction);
} catch (err) {
if (err instanceof EventSourcing.AggregateRootNotFoundException) {
throw new NotFoundException("Transaction not found");
}
throw err;
if (!await this.writeModel.exists(command.uuid)) {
throw new NotFoundException("Transaction not found");
}
const transaction = await this.writeModel.load(command.uuid);
transaction.confirm();
await this.writeModel.save(transaction);
}
}
29 changes: 12 additions & 17 deletions src/Billing/Transaction/Application/Create/Handler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import Probe from "@Shared/Infrastructure/Audit/Probe";
import { Application, EventSourcing } from "hollywood-js";
import { Application } from "hollywood-js";
import type { IAppError } from "hollywood-js/src/Application/Bus/CallbackArg";
import { inject, injectable } from "inversify";
import type { Counter } from "prom-client";
import ConflictException from "../../../Shared/Domain/Exceptions/ConflictException";
import Transaction from "../../Domain/Transaction";
import ITransactionWriteRepository from "../../Domain/WriteRepository";
import CreateCommand from "./Command";

@injectable()
Expand All @@ -14,9 +15,8 @@ export default class Create implements Application.ICommandHandler {
private readonly success: Counter<string>;

constructor(
@inject(
"infrastructure.transaction.eventStore",
) private readonly writeModel: EventSourcing.EventStore<Transaction>,
@inject("infrastructure.transaction.writeRepository")
private readonly writeModel: ITransactionWriteRepository,
) {
this.error = Probe.counter({ name: "transaction_create_error", help: "Counter of the incremental transaction create errors"});
this.conflicts = Probe.counter({ name: "transaction_create_conflict", help: "Counter of the incremental transaction create conflicts"});
Expand All @@ -25,19 +25,9 @@ export default class Create implements Application.ICommandHandler {

@Application.autowiring
public async handle(command: CreateCommand): Promise<void | IAppError> {

try {
await this.writeModel.load(command.uuid.toIdentity());
if (await this.writeModel.exists(command.uuid)) {
this.conflicts.inc(1);
throw new ConflictException("Already exists");
} catch (err) {
if (err instanceof ConflictException) {
throw err;
}
if (!(err instanceof EventSourcing.AggregateRootNotFoundException)) {
this.error.inc(1);
throw err;
}
}

const transaction: Transaction = Transaction.create(
Expand All @@ -46,7 +36,12 @@ export default class Create implements Application.ICommandHandler {
command.price,
);

await this.writeModel.save(transaction);
this.success.inc(1);
try {
await this.writeModel.save(transaction);
this.success.inc(1);
} catch (err) {
this.error.inc(1);
throw err;
}
}
}
22 changes: 9 additions & 13 deletions src/Billing/Transaction/Application/Fail/Handler.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import { Application, EventSourcing } from "hollywood-js";
import { Application } from "hollywood-js";
import type { IAppError } from "hollywood-js/src/Application/Bus/CallbackArg";
import { inject, injectable } from "inversify";
import NotFoundException from "../../../Shared/Domain/Exceptions/NotFoundException";
import Transaction from "../../Domain/Transaction";
import ITransactionWriteRepository from "../../Domain/WriteRepository";
import FailCommand from "./Command";

@injectable()
export default class Fail implements Application.ICommandHandler {
constructor(
@inject("infrastructure.transaction.eventStore")
private readonly writeModel: EventSourcing.EventStore<Transaction>,
@inject("infrastructure.transaction.writeRepository")
private readonly writeModel: ITransactionWriteRepository,
) {}

@Application.autowiring
public async handle(command: FailCommand): Promise<void | IAppError> {
try {
const transaction = await this.writeModel.load(command.uuid.toIdentity()) as Transaction;
transaction.fail(command.reason);
await this.writeModel.save(transaction);
} catch (err) {
if (err instanceof EventSourcing.AggregateRootNotFoundException) {
throw new NotFoundException("Transaction not found");
}
throw err;
if (!await this.writeModel.exists(command.uuid)) {
throw new NotFoundException("Transaction not found");
}
const transaction = await this.writeModel.load(command.uuid);
transaction.fail(command.reason);
await this.writeModel.save(transaction);
}
}
22 changes: 9 additions & 13 deletions src/Billing/Transaction/Application/Refund/Handler.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import { Application, EventSourcing } from "hollywood-js";
import { Application } from "hollywood-js";
import type { IAppError } from "hollywood-js/src/Application/Bus/CallbackArg";
import { inject, injectable } from "inversify";
import NotFoundException from "../../../Shared/Domain/Exceptions/NotFoundException";
import Transaction from "../../Domain/Transaction";
import ITransactionWriteRepository from "../../Domain/WriteRepository";
import RefundCommand from "./Command";

@injectable()
export default class Refund implements Application.ICommandHandler {
constructor(
@inject("infrastructure.transaction.eventStore")
private readonly writeModel: EventSourcing.EventStore<Transaction>,
@inject("infrastructure.transaction.writeRepository")
private readonly writeModel: ITransactionWriteRepository,
) {}

@Application.autowiring
public async handle(command: RefundCommand): Promise<void | IAppError> {
try {
const transaction = await this.writeModel.load(command.uuid.toIdentity()) as Transaction;
transaction.refund();
await this.writeModel.save(transaction);
} catch (err) {
if (err instanceof EventSourcing.AggregateRootNotFoundException) {
throw new NotFoundException("Transaction not found");
}
throw err;
if (!await this.writeModel.exists(command.uuid)) {
throw new NotFoundException("Transaction not found");
}
const transaction = await this.writeModel.load(command.uuid);
transaction.refund();
await this.writeModel.save(transaction);
}
}
18 changes: 18 additions & 0 deletions src/Billing/Transaction/Domain/WriteRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type Transaction from "./Transaction";
import type TransactionId from "./ValueObject/TransactionId";

/**
* Write-side repository interface for the Transaction aggregate.
*
* Abstracts the EventStore so command handlers depend on a domain interface
* instead of the infrastructure EventStore class directly, and to encapsulate
* the existence check cleanly.
*
* Source: Vernon (IDDD), p. 356 — "Command handlers orchestrate; they do not
* query to make decisions." Using exists() avoids catch-to-check anti-pattern.
*/
export default interface ITransactionWriteRepository {
exists(id: TransactionId): Promise<boolean>;
load(id: TransactionId): Promise<Transaction>;
save(transaction: Transaction): Promise<void>;
}
2 changes: 2 additions & 0 deletions src/Billing/Transaction/Infrastructure/TransactionModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Transaction from "@Transaction/Domain/Transaction";
import {Transactions} from "@Transaction/Infrastructure/ReadModel/Mapping/Transactions";
import PostgresProjector from "@Transaction/Infrastructure/ReadModel/Projections/PostgresProjector";
import PostgresRepository from "@Transaction/Infrastructure/ReadModel/Repository/PostgresRepository";
import EventStoreTransactionRepository from "@Transaction/Infrastructure/WriteModel/EventStoreTransactionRepository";
import { EventSourcing, Framework} from "hollywood-js";
import type {interfaces} from "inversify";
import {getRepository} from "typeorm";
Expand All @@ -38,6 +39,7 @@ export const services = (new Map())
],
})
.set("infrastructure.transaction.eventStore", { eventStore: Transaction })
.set("infrastructure.transaction.writeRepository", { instance: EventStoreTransactionRepository })
.set(Framework.SERVICES_ALIAS.COMMAND_MIDDLEWARE, { collection: [
LoggerMiddleware,
]})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Transaction from "@Transaction/Domain/Transaction";
import TransactionId from "@Transaction/Domain/ValueObject/TransactionId";
import ITransactionWriteRepository from "@Transaction/Domain/WriteRepository";
import { EventSourcing } from "hollywood-js";
import { inject, injectable } from "inversify";

/**
* Write-side repository implementation backed by the EventStore.
*
* Encapsulates the catch-to-check existence pattern in one place so that
* command handlers can call exists() without relying on exception control flow.
*/
@injectable()
export default class EventStoreTransactionRepository implements ITransactionWriteRepository {
constructor(
@inject("infrastructure.transaction.eventStore")
private readonly eventStore: EventSourcing.EventStore<Transaction>,
) {}

public async exists(id: TransactionId): Promise<boolean> {
try {
await this.eventStore.load(id.toIdentity());
return true;
} catch (err) {
if (err instanceof EventSourcing.AggregateRootNotFoundException) {
return false;
}
throw err;
}
}

public async load(id: TransactionId): Promise<Transaction> {
return await this.eventStore.load(id.toIdentity()) as Transaction;
}

public async save(transaction: Transaction): Promise<void> {
await this.eventStore.save(transaction);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { EventSourcing } from "hollywood-js";
import { inject, injectable } from "inversify";
import Transaction from "@Transaction/Domain/Transaction";
import ITransactionWriteRepository from "@Transaction/Domain/WriteRepository";
import TransactionId from "@Transaction/Domain/ValueObject/TransactionId";

/**
* In-memory ITransactionWriteRepository for tests.
* Shares the same InMemoryEventStore as InMemoryTransactionRepository
* so both the write-side (handlers) and the test assertions see the same state.
*/
@injectable()
export class InMemoryWriteRepository implements ITransactionWriteRepository {
constructor(
@inject("infrastructure.transaction.eventStore")
private readonly eventStore: EventSourcing.EventStore<Transaction>,
) {}

public async exists(id: TransactionId): Promise<boolean> {
try {
await this.eventStore.load(id.toIdentity());
return true;
} catch (err) {
if (err instanceof EventSourcing.AggregateRootNotFoundException) {
return false;
}
throw err;
}
}

public async load(id: TransactionId): Promise<Transaction> {
return await this.eventStore.load(id.toIdentity()) as Transaction;
}

public async save(transaction: Transaction): Promise<void> {
await this.eventStore.save(transaction);
}
}
5 changes: 5 additions & 0 deletions tests/TestKernelFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import TransactionWasCreated from "@Transaction/Domain/Events/TransactionWasCrea
import TransactionWasRefunded from "@Transaction/Domain/Events/TransactionWasRefunded";
import {TransactionInMemoryProjector} from "@Tests/Transaction/Infrastructure/InMemoryProjector";
import {InMemoryReadModelRepository} from "@Tests/Transaction/Infrastructure/InMemoryReadModelRepository";
import {InMemoryWriteRepository} from "@Tests/Transaction/Infrastructure/InMemoryWriteRepository";
import {EventCollectorListener} from "@Tests/Shared/Infrastructure/EventCollectorListener";
import {InMemoryTransactionRepository} from "@Tests/Transaction/Infrastructure/InMemoryRepository";

Expand All @@ -28,6 +29,10 @@ const testServices = new Map([
"infrastructure.transaction.readModel.repository",
{ overwrite: true, instance: InMemoryReadModelRepository },
],
[
"infrastructure.transaction.writeRepository",
{ overwrite: true, instance: InMemoryWriteRepository },
],
[
"infrastructure.orm.readModel.postgresConnection",
{
Expand Down