35 KiB
NestJS Implementation Plan for AutoStore
Overview
Implementation of AutoStore system using NestJS with TypeScript, following enterprise-grade DDD (Domain-Driven Design), Clean Architecture, and Hexagonal Architecture principles. This system will manage items with expiration dates and automatically reorder when items expire, demonstrating best practices for scalable, maintainable enterprise applications.
Architecture Approach
- Domain-Driven Design (DDD) with rich domain models, aggregates, bounded contexts, and domain events
- Clean Architecture with strict dependency inversion and clear layer boundaries
- Hexagonal Architecture with ports and adapters for external integrations
- CQRS (Command Query Responsibility Segregation) for separating read and write operations
- Event-Driven Architecture with domain events for cross-cutting concerns
- Dependency Injection leveraging NestJS IoC container with proper module organization
- Test-Driven Development approach with comprehensive unit and integration tests
Business Domain Analysis
Core Domain Concepts
- Item Aggregate: Represents a stored item with business rules around expiration and reordering
- User Aggregate: Represents system users with authentication and authorization
- ItemExpiration Domain Service: Handles expiration logic and reordering business rules
- Domain Events: ItemExpired, ItemCreated, ItemDeleted for event-driven communication
Business Rules (Domain)
- Each item has a name, expiration date, and order URL
- Expired items are automatically removed and reordered
- Expired items can be added, triggering immediate ordering
- Every item belongs to a user with ownership validation
- Only the item's owner can manage it
- System must check all items for expiration on startup
Layered Architecture Implementation
1. Domain Layer (Core Business Logic)
1.1 Aggregates
File: src/domain/aggregates/item.aggregate.ts
Purpose: Root aggregate for Item entity with business logic and invariants
export class ItemAggregate {
private readonly _id: ItemId;
private readonly _name: string;
private readonly _expirationDate: ExpirationDate;
private readonly _orderUrl: OrderUrl;
private readonly _userId: UserId;
private readonly _createdAt: DateTime;
private _domainEvents: DomainEvent[] = [];
constructor(
id: ItemId,
name: string,
expirationDate: ExpirationDate,
orderUrl: OrderUrl,
userId: UserId
) {
this._id = id;
this._name = name;
this._expirationDate = expirationDate;
this._orderUrl = orderUrl;
this._userId = userId;
this._createdAt = DateTime.now();
this.validate();
}
// Business methods with domain events
public checkExpiration(currentTime: DateTime): boolean {
if (this._expirationDate.isExpired(currentTime)) {
this.addDomainEvent(new ItemExpiredEvent(
this._id,
this._name,
this._userId,
this._orderUrl
));
return true;
}
return false;
}
// Domain event management
private addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
public pullDomainEvents(): DomainEvent[] {
const events = this._domainEvents;
this._domainEvents = [];
return events;
}
// Getters with value objects
get id(): ItemId { return this._id; }
get name(): string { return this._name; }
get expirationDate(): ExpirationDate { return this._expirationDate; }
get orderUrl(): OrderUrl { return this._orderUrl; }
get userId(): UserId { return this._userId; }
get createdAt(): DateTime { return this._createdAt; }
private validate(): void {
if (!this._name || this._name.trim().length === 0) {
throw new InvalidItemDataException('Item name cannot be empty');
}
// Additional business invariants
}
}
File: src/domain/aggregates/user.aggregate.ts
Purpose: User aggregate with authentication and authorization logic
1.2 Value Objects
File: src/domain/value-objects/item-id.vo.ts
Purpose: Strongly typed item identifier with validation
export class ItemId {
private readonly _value: string;
constructor(value: string) {
if (!this.isValidUuid(value)) {
throw new InvalidItemIdException('Invalid Item ID format');
}
this._value = value;
}
private isValidUuid(value: string): boolean {
// UUID v4 validation
const uuidRegex = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
return uuidRegex.test(value);
}
get value(): string { return this._value; }
equals(other: ItemId): boolean {
return this._value === other._value;
}
toString(): string {
return this._value;
}
}
File: src/domain/value-objects/expiration-date.vo.ts
Purpose: Immutable expiration date with business logic
export class ExpirationDate {
private readonly _value: DateTime;
constructor(value: Date | DateTime) {
const dateTime = DateTime.isDateTime(value) ? value : DateTime.fromJSDate(value);
if (dateTime <= DateTime.now()) {
throw new InvalidExpirationDateException('Expiration date must be in the future');
}
this._value = dateTime;
}
isExpired(currentTime: DateTime): boolean {
return this._value <= currentTime;
}
daysUntilExpiration(currentTime: DateTime): number {
return Math.ceil(this._value.diff(currentTime, 'days').days);
}
get value(): DateTime { return this._value; }
toISOString(): string {
return this._value.toISO();
}
}
1.3 Domain Services
File: src/domain/services/item-expiration.domain-service.ts
Purpose: Domain service for complex expiration logic
export class ItemExpirationDomainService {
constructor(
private readonly timeProvider: ITimeProvider
) {}
public processItemExpiration(item: ItemAggregate): ItemExpirationResult {
const currentTime = this.timeProvider.now();
const isExpired = item.checkExpiration(currentTime);
if (isExpired) {
return new ItemExpirationResult(
true,
new OrderRequest(
item.id,
item.name,
item.userId,
item.orderUrl,
currentTime
)
);
}
return new ItemExpirationResult(false, null);
}
public getExpiredItemsSpecification(currentTime: DateTime): Specification<ItemAggregate> {
return new ItemExpirationSpecification(currentTime);
}
}
1.4 Domain Events
File: src/domain/events/item-expired.event.ts
Purpose: Domain event for item expiration
export class ItemExpiredEvent implements DomainEvent {
readonly occurredOn: DateTime;
readonly eventId: string;
readonly aggregateId: string;
readonly version: number = 1;
constructor(
public readonly itemId: ItemId,
public readonly itemName: string,
public readonly userId: UserId,
public readonly orderUrl: OrderUrl
) {
this.occurredOn = DateTime.now();
this.eventId = uuidv4();
this.aggregateId = itemId.value;
}
getEventName(): string {
return 'item.expired';
}
}
1.5 Specifications
File: src/domain/specifications/item-expiration.spec.ts
Purpose: Specification pattern for complex queries
export class ItemExpirationSpecification implements Specification<ItemAggregate> {
constructor(private readonly currentTime: DateTime) {}
isSatisfiedBy(candidate: ItemAggregate): boolean {
return candidate.expirationDate.isExpired(this.currentTime);
}
// For TypeORM/Query Builder integration
toCriteria(): object {
return {
expirationDate: {
$lte: this.currentTime.toISO()
}
};
}
}
2. Application Layer (Use Cases)
2.1 Commands (Write Operations)
File: src/application/commands/add-item/add-item.command.ts
Purpose: Command for creating new items
export class AddItemCommand {
constructor(
public readonly name: string,
public readonly expirationDate: string,
public readonly orderUrl: string,
public readonly userId: string
) {}
}
File: src/application/commands/add-item/add-item.command-handler.ts
Purpose: Command handler with CQRS pattern
@CommandHandler(AddItemCommand)
export class AddItemCommandHandler implements ICommandHandler<AddItemCommand, string> {
constructor(
private readonly itemRepository: IItemRepository,
private readonly expirationDomainService: ItemExpirationDomainService,
private readonly eventBus: IEventBus,
private readonly logger: Logger
) {}
async execute(command: AddItemCommand): Promise<string> {
try {
const itemAggregate = new ItemAggregate(
new ItemId(uuidv4()),
command.name,
new ExpirationDate(new Date(command.expirationDate)),
new OrderUrl(command.orderUrl),
new UserId(command.userId)
);
// Check for immediate expiration
const expirationResult = this.expirationDomainService.processItemExpiration(itemAggregate);
if (expirationResult.isExpired) {
// Publish domain event for expired item
await this.eventBus.publish(itemAggregate.pullDomainEvents());
return null; // Item expired immediately, not stored
}
// Save non-expired item
await this.itemRepository.save(itemAggregate);
// Publish ItemCreated event
await this.eventBus.publish(itemAggregate.pullDomainEvents());
this.logger.log(`Item created: ${itemAggregate.id.value}`);
return itemAggregate.id.value;
} catch (error) {
this.logger.error(`Failed to add item: ${error.message}`);
throw new ApplicationError('Failed to add item', error);
}
}
}
2.2 Queries (Read Operations)
File: src/application/queries/get-item/get-item.query.ts
Purpose: Query for retrieving item details
export class GetItemQuery {
constructor(
public readonly itemId: string,
public readonly userId: string
) {}
}
File: src/application/queries/get-item/get-item.query-handler.ts
Purpose: Query handler with CQRS pattern
@QueryHandler(GetItemQuery)
export class GetItemQueryHandler implements IQueryHandler<GetItemQuery, ItemDto> {
constructor(
private readonly itemRepository: IItemRepository,
private readonly logger: Logger
) {}
async execute(query: GetItemQuery): Promise<ItemDto> {
try {
const item = await this.itemRepository.findById(new ItemId(query.itemId));
if (!item) {
throw new ItemNotFoundException(`Item not found: ${query.itemId}`);
}
// Ownership validation
if (!item.userId.equals(new UserId(query.userId))) {
throw new UnauthorizedAccessException('User does not own this item');
}
return this.mapToItemDto(item);
} catch (error) {
this.logger.error(`Failed to get item: ${error.message}`);
throw new ApplicationError('Failed to get item', error);
}
}
private mapToItemDto(item: ItemAggregate): ItemDto {
return new ItemDto(
item.id.value,
item.name,
item.expirationDate.toISOString(),
item.orderUrl.value,
item.userId.value,
item.createdAt.toISO()
);
}
}
2.3 DTOs
File: src/application/dto/item.dto.ts
Purpose: Data transfer objects for API communication
export class ItemDto {
constructor(
public readonly id: string,
public readonly name: string,
public readonly expirationDate: string,
public readonly orderUrl: string,
public readonly userId: string,
public readonly createdAt: string
) {}
}
export class CreateItemRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;
@IsString()
@IsNotEmpty()
@IsISO8601()
expirationDate: string;
@IsString()
@IsNotEmpty()
@IsUrl()
orderUrl: string;
}
3. Infrastructure Layer (Technical Implementation)
3.1 Persistence
File: src/infrastructure/persistence/typeorm/entities/item.typeorm-entity.ts
Purpose: TypeORM entity for database persistence
@Entity('items')
export class ItemTypeormEntity {
@PrimaryColumn('uuid')
id: string;
@Column()
name: string;
@Column({ type: 'timestamp with time zone' })
expirationDate: Date;
@Column()
orderUrl: string;
@Column('uuid')
userId: string;
@Column({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
// Static factory methods for domain mapping
static fromDomain(item: ItemAggregate): ItemTypeormEntity {
const entity = new ItemTypeormEntity();
entity.id = item.id.value;
entity.name = item.name;
entity.expirationDate = item.expirationDate.value.toJSDate();
entity.orderUrl = item.orderUrl.value;
entity.userId = item.userId.value;
entity.createdAt = item.createdAt.toJSDate();
return entity;
}
toDomain(): ItemAggregate {
return new ItemAggregate(
new ItemId(this.id),
this.name,
new ExpirationDate(this.expirationDate),
new OrderUrl(this.orderUrl),
new UserId(this.userId)
);
}
}
File: src/infrastructure/persistence/repositories/typeorm-item.repository.ts
Purpose: TypeORM implementation of item repository
@Injectable()
export class TypeormItemRepository implements IItemRepository {
constructor(
@InjectRepository(ItemTypeormEntity)
private readonly repository: Repository<ItemTypeormEntity>,
private readonly logger: Logger
) {}
async save(item: ItemAggregate): Promise<void> {
try {
const entity = ItemTypeormEntity.fromDomain(item);
await this.repository.save(entity);
this.logger.log(`Item saved: ${item.id.value}`);
} catch (error) {
this.logger.error(`Failed to save item: ${error.message}`);
throw new RepositoryException('Failed to save item', error);
}
}
async findById(id: ItemId): Promise<ItemAggregate | null> {
try {
const entity = await this.repository.findOne({ where: { id: id.value } });
return entity ? entity.toDomain() : null;
} catch (error) {
this.logger.error(`Failed to find item by ID: ${error.message}`);
throw new RepositoryException('Failed to find item', error);
}
}
async findByUserId(userId: UserId): Promise<ItemAggregate[]> {
try {
const entities = await this.repository.find({ where: { userId: userId.value } });
return entities.map(entity => entity.toDomain());
} catch (error) {
this.logger.error(`Failed to find items by user ID: ${error.message}`);
throw new RepositoryException('Failed to find items', error);
}
}
async findWhere(specification: Specification<ItemAggregate>): Promise<ItemAggregate[]> {
try {
const criteria = (specification as ItemExpirationSpecification).toCriteria();
const entities = await this.repository.find({ where: criteria });
return entities.map(entity => entity.toDomain());
} catch (error) {
this.logger.error(`Failed to find items by specification: ${error.message}`);
throw new RepositoryException('Failed to find items', error);
}
}
async delete(id: ItemId): Promise<void> {
try {
await this.repository.delete({ id: id.value });
this.logger.log(`Item deleted: ${id.value}`);
} catch (error) {
this.logger.error(`Failed to delete item: ${error.message}`);
throw new RepositoryException('Failed to delete item', error);
}
}
async exists(id: ItemId): Promise<boolean> {
try {
const count = await this.repository.count({ where: { id: id.value } });
return count > 0;
} catch (error) {
this.logger.error(`Failed to check item existence: ${error.message}`);
throw new RepositoryException('Failed to check item existence', error);
}
}
}
3.2 External Services
File: src/infrastructure/external/http-order.service.ts
Purpose: HTTP client for ordering items
@Injectable()
export class HttpOrderService implements IOrderService {
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
private readonly logger: Logger
) {}
async orderItem(orderRequest: OrderRequest): Promise<void> {
try {
const payload = {
itemId: orderRequest.itemId.value,
itemName: orderRequest.itemName,
userId: orderRequest.userId.value,
expirationDate: orderRequest.expirationDate.toISOString(),
orderTimestamp: orderRequest.orderTimestamp.toISOString()
};
const response = await firstValueFrom(
this.httpService.post(orderRequest.orderUrl.value, payload, {
timeout: this.configService.get('order.timeout', 30000),
headers: {
'Content-Type': 'application/json',
'User-Agent': 'AutoStore/1.0',
'X-Request-ID': uuidv4()
}
}).pipe(
catchError(error => {
throw new OrderException(
`Failed to order item: ${error.message}`,
orderRequest.orderUrl.value
);
})
)
);
if (response.status >= 400) {
throw new OrderException(
`Order failed with status ${response.status}`,
orderRequest.orderUrl.value
);
}
this.logger.log(`Order placed successfully for item ${orderRequest.itemId.value}`);
} catch (error) {
this.logger.error(`Failed to place order: ${error.message}`);
throw error;
}
}
}
3.3 Authentication
File: src/infrastructure/auth/jwt.strategy.ts
Purpose: JWT authentication strategy
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly userRepository: IUserRepository
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('jwt.secret'),
});
}
async validate(payload: JwtPayload): Promise<UserPayload> {
const user = await this.userRepository.findById(new UserId(payload.sub));
if (!user) {
throw new UnauthorizedException('Invalid token');
}
return {
userId: user.id.value,
username: user.username
};
}
}
4. Presentation Layer (API)
4.1 Controllers
File: src/presentation/http/controllers/items.controller.ts
Purpose: REST API controller for items
@ApiTags('items')
@Controller('api/v1/items')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class ItemsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly logger: Logger
) {}
@Post()
@ApiOperation({ summary: 'Create a new item' })
@ApiResponse({ status: 201, description: 'Item created successfully' })
@ApiResponse({ status: 400, description: 'Invalid input data' })
async createItem(
@Body() createItemDto: CreateItemRequestDto,
@Req() request: Request
): Promise<ApiResponse<string>> {
try {
const user = request.user as UserPayload;
const command = new AddItemCommand(
createItemDto.name,
createItemDto.expirationDate,
createItemDto.orderUrl,
user.userId
);
const itemId = await this.commandBus.execute(command);
return itemId
? new ApiResponse(true, 'Item created successfully', itemId)
: new ApiResponse(true, 'Item expired and ordered successfully', null);
} catch (error) {
this.logger.error(`Failed to create item: ${error.message}`);
throw new HttpException(
new ApiResponse(false, error.message, null),
HttpStatus.BAD_REQUEST
);
}
}
@Get(':id')
@ApiOperation({ summary: 'Get item by ID' })
@ApiResponse({ status: 200, description: 'Item retrieved successfully' })
@ApiResponse({ status: 404, description: 'Item not found' })
async getItem(
@Param('id') itemId: string,
@Req() request: Request
): Promise<ApiResponse<ItemDto>> {
try {
const user = request.user as UserPayload;
const query = new GetItemQuery(itemId, user.userId);
const item = await this.queryBus.execute(query);
return new ApiResponse(true, 'Item retrieved successfully', item);
} catch (error) {
this.logger.error(`Failed to get item: ${error.message}`);
throw new HttpException(
new ApiResponse(false, error.message, null),
error instanceof ItemNotFoundException ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST
);
}
}
@Get()
@ApiOperation({ summary: 'Get all user items' })
@ApiResponse({ status: 200, description: 'Items retrieved successfully' })
async listItems(@Req() request: Request): Promise<ApiResponse<ItemDto[]>> {
try {
const user = request.user as UserPayload;
const query = new ListItemsQuery(user.userId);
const items = await this.queryBus.execute(query);
return new ApiResponse(true, 'Items retrieved successfully', items);
} catch (error) {
this.logger.error(`Failed to list items: ${error.message}`);
throw new HttpException(
new ApiResponse(false, error.message, null),
HttpStatus.BAD_REQUEST
);
}
}
@Delete(':id')
@ApiOperation({ summary: 'Delete item' })
@ApiResponse({ status: 204, description: 'Item deleted successfully' })
@ApiResponse({ status: 404, description: 'Item not found' })
@HttpCode(HttpStatus.NO_CONTENT)
async deleteItem(
@Param('id') itemId: string,
@Req() request: Request
): Promise<void> {
try {
const user = request.user as UserPayload;
const command = new DeleteItemCommand(itemId, user.userId);
await this.commandBus.execute(command);
} catch (error) {
this.logger.error(`Failed to delete item: ${error.message}`);
throw new HttpException(
new ApiResponse(false, error.message, null),
error instanceof ItemNotFoundException ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST
);
}
}
}
4.2 Exception Handling
File: src/presentation/http/filters/exception.filter.ts
Purpose: Global exception filter for consistent error responses
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
constructor(
private readonly logger: Logger
) {}
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status: number;
let message: string;
let error: string;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse() as any;
message = exceptionResponse.message || exception.message;
error = exceptionResponse.error || 'Http Exception';
} else if (exception instanceof DomainException) {
status = HttpStatus.BAD_REQUEST;
message = exception.message;
error = 'Domain Exception';
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'Internal server error';
error = 'Internal Server Error';
}
this.logger.error(
`${request.method} ${request.url} - ${status} - ${message}`,
exception instanceof Error ? exception.stack : undefined
);
response.status(status).json({
success: false,
message,
error,
timestamp: new Date().toISOString(),
path: request.url
});
}
}
5. Cross-Cutting Concerns
5.1 Event Handlers
File: src/infrastructure/events/handlers/item-expired.handler.ts
Purpose: Event handler for item expiration
@EventsHandler(ItemExpiredEvent)
export class ItemExpiredHandler implements IEventHandler<ItemExpiredEvent> {
constructor(
private readonly orderService: IOrderService,
private readonly itemRepository: IItemRepository,
private readonly logger: Logger
) {}
async handle(event: ItemExpiredEvent): Promise<void> {
try {
this.logger.log(`Processing expired item: ${event.itemId.value}`);
const orderRequest = new OrderRequest(
event.itemId,
event.itemName,
event.userId,
event.orderUrl,
DateTime.now()
);
await this.orderService.orderItem(orderRequest);
await this.itemRepository.delete(event.itemId);
this.logger.log(`Expired item processed successfully: ${event.itemId.value}`);
} catch (error) {
this.logger.error(`Failed to process expired item ${event.itemId.value}: ${error.message}`);
// Implement retry logic or dead letter queue
}
}
}
5.2 Scheduling
File: src/infrastructure/schedulers/expired-items.scheduler.ts
Purpose: Scheduled job for processing expired items
@Injectable()
export class ExpiredItemsScheduler {
constructor(
private readonly itemRepository: IItemRepository,
private readonly expirationDomainService: ItemExpirationDomainService,
private readonly eventBus: IEventBus,
private readonly logger: Logger
) {}
@Cron('0 * * * * *') // Every minute
async handleExpiredItems(): Promise<void> {
try {
this.logger.log('Starting expired items processing');
const currentTime = this.timeProvider.now();
const specification = this.expirationDomainService.getExpiredItemsSpecification(currentTime);
const expiredItems = await this.itemRepository.findWhere(specification);
for (const item of expiredItems) {
const expirationResult = this.expirationDomainService.processItemExpiration(item);
if (expirationResult.isExpired) {
await this.eventBus.publish(item.pullDomainEvents());
}
}
this.logger.log(`Processed ${expiredItems.length} expired items`);
} catch (error) {
this.logger.error(`Failed to process expired items: ${error.message}`);
}
}
}
6. Module Organization
File: src/infrastructure/modules/item.module.ts
Purpose: Item feature module with proper dependency injection
@Module({
imports: [
TypeOrmModule.forFeature([ItemTypeormEntity]),
CqrsModule,
HttpModule,
],
controllers: [ItemsController],
providers: [
// Domain Services
ItemExpirationDomainService,
// Repositories
{ provide: IItemRepository, useClass: TypeormItemRepository },
// External Services
{ provide: IOrderService, useClass: HttpOrderService },
// Command Handlers
AddItemCommandHandler,
DeleteItemCommandHandler,
// Query Handlers
GetItemQueryHandler,
ListItemsQueryHandler,
// Event Handlers
ItemExpiredHandler,
// Schedulers
ExpiredItemsScheduler,
// Value Objects (as providers for dependency injection)
{ provide: 'ITimeProvider', useClass: SystemTimeProvider }
],
exports: [
IItemRepository,
ItemExpirationDomainService
]
})
export class ItemModule {}
7. Testing Strategy
7.1 Unit Tests
File: tests/unit/domain/aggregates/item.aggregate.spec.ts
Purpose: Unit tests for Item aggregate
describe('ItemAggregate', () => {
let item: ItemAggregate;
let itemId: ItemId;
let expirationDate: ExpirationDate;
let orderUrl: OrderUrl;
let userId: UserId;
beforeEach(() => {
itemId = new ItemId(uuidv4());
expirationDate = new ExpirationDate(DateTime.now().plus({ days: 1 }));
orderUrl = new OrderUrl('https://example.com/order');
userId = new UserId(uuidv4());
item = new ItemAggregate(itemId, 'Test Item', expirationDate, orderUrl, userId);
});
describe('constructor', () => {
it('should create a valid item', () => {
expect(item.id).toEqual(itemId);
expect(item.name).toBe('Test Item');
expect(item.expirationDate).toEqual(expirationDate);
expect(item.orderUrl).toEqual(orderUrl);
expect(item.userId).toEqual(userId);
});
it('should throw error for empty name', () => {
expect(() => new ItemAggregate(itemId, '', expirationDate, orderUrl, userId))
.toThrow(InvalidItemDataException);
});
});
describe('checkExpiration', () => {
it('should return true for expired item', () => {
const pastDate = new ExpirationDate(DateTime.now().minus({ days: 1 }));
const expiredItem = new ItemAggregate(itemId, 'Expired Item', pastDate, orderUrl, userId);
const currentTime = DateTime.now();
const result = expiredItem.checkExpiration(currentTime);
expect(result).toBe(true);
expect(expiredItem.pullDomainEvents()).toHaveLength(1);
});
it('should return false for non-expired item', () => {
const currentTime = DateTime.now();
const result = item.checkExpiration(currentTime);
expect(result).toBe(false);
expect(item.pullDomainEvents()).toHaveLength(0);
});
});
});
7.2 Integration Tests
File: tests/integration/commands/add-item.command.integration.spec.ts
Purpose: Integration tests for AddItem command
describe('AddItemCommand Integration', () => {
let commandBus: CommandBus;
let eventBus: EventBus;
let itemRepository: IItemRepository;
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [
CqrsModule,
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
entities: [ItemTypeormEntity],
synchronize: true,
}),
ItemModule,
],
}).compile();
commandBus = module.get<CommandBus>(CommandBus);
eventBus = module.get<EventBus>(EventBus);
itemRepository = module.get<IItemRepository>(IItemRepository);
});
it('should create a new item', async () => {
const command = new AddItemCommand(
'Test Item',
DateTime.now().plus({ days: 1 }).toISO(),
'https://example.com/order',
uuidv4()
);
const itemId = await commandBus.execute(command);
expect(itemId).toBeDefined();
const savedItem = await itemRepository.findById(new ItemId(itemId));
expect(savedItem).toBeDefined();
expect(savedItem.name).toBe('Test Item');
});
it('should handle expired item immediately', async () => {
const command = new AddItemCommand(
'Expired Item',
DateTime.now().minus({ days: 1 }).toISO(),
'https://example.com/order',
uuidv4()
);
const itemId = await commandBus.execute(command);
expect(itemId).toBeNull();
const savedItem = await itemRepository.findById(new ItemId('any-id'));
expect(savedItem).toBeNull();
});
});
8. Configuration
File: src/config/configuration.ts
Purpose: Application configuration
export default () => ({
app: {
name: 'AutoStore',
version: '1.0.0',
port: process.env.PORT || 3000,
},
database: {
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 5432,
username: process.env.DB_USERNAME || 'autostore',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_DATABASE || 'autostore',
},
jwt: {
secret: process.env.JWT_SECRET || 'your-secret-key',
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
},
order: {
timeout: parseInt(process.env.ORDER_TIMEOUT, 10) || 30000,
retryAttempts: parseInt(process.env.ORDER_RETRY_ATTEMPTS, 10) || 3,
},
logging: {
level: process.env.LOG_LEVEL || 'info',
},
});
Implementation Roadmap
Phase 1: Core Domain (Week 1)
- Domain Layer: Implement aggregates, value objects, domain services, and events
- Unit Tests: Comprehensive unit tests for all domain components
- Domain Specifications: Implement specification pattern for complex queries
Phase 2: Application Layer (Week 2)
- CQRS Setup: Implement command and query buses
- Use Cases: Implement all commands and queries with proper handlers
- DTOs: Create request and response DTOs with validation
- Application Services: Implement domain services for complex business logic
Phase 3: Infrastructure Layer (Week 3)
- Persistence: Implement TypeORM repositories and entities
- External Services: Implement HTTP client for ordering
- Authentication: Implement JWT authentication and authorization
- Configuration: Set up configuration management
Phase 4: Presentation Layer (Week 4)
- Controllers: Implement REST API controllers
- Exception Handling: Implement global exception filters
- API Documentation: Add Swagger/OpenAPI documentation
- Validation: Implement request validation and transformations
Phase 5: Cross-Cutting Concerns (Week 5)
- Event Handling: Implement domain event handlers
- Scheduling: Implement background job for expired items
- Logging: Implement structured logging throughout the application
- Monitoring: Add health checks and metrics
Phase 6: Testing and Deployment (Week 6)
- Integration Tests: Comprehensive integration tests
- E2E Tests: End-to-end testing with real HTTP requests
- Docker: Containerize the application
- Documentation: Complete documentation and README
Best Practices and Patterns
1. SOLID Principles
- Single Responsibility: Each class has one reason to change
- Open/Closed: Open for extension, closed for modification
- Liskov Substitution: Subtypes must be substitutable for base types
- Interface Segregation: Client-specific interfaces
- Dependency Inversion: Depend on abstractions, not concretions
2. DDD Patterns
- Aggregates: Consistency boundaries and transactional units
- Value Objects: Immutable objects with equality based on values
- Domain Services: Business logic that doesn't fit in entities
- Domain Events: Decoupled communication through events
- Repositories: Collection-like access to aggregates
3. Clean Architecture
- Dependency Rule: Dependencies point inward
- Framework Independence: Business logic doesn't depend on frameworks
- Testability: Easy to test business logic in isolation
- UI Independence: Business logic doesn't depend on UI
4. NestJS Specific Patterns
- Modules: Organize related functionality
- Dependency Injection: Leverage NestJS IoC container
- Decorators: Use decorators for metadata and configuration
- Interceptors: Implement cross-cutting concerns
- Guards: Implement authentication and authorization
Development Guidelines
1. Code Quality
- Use TypeScript strict mode
- Implement ESLint and Prettier
- Use meaningful variable and function names
- Keep functions small and focused
- Write DRY (Don't Repeat Yourself) code
2. Error Handling
- Use custom exception classes
- Implement proper error logging
- Provide meaningful error messages
- Handle both domain and technical errors
- Implement graceful degradation
3. Performance Considerations
- Implement database connection pooling
- Use caching for frequently accessed data
- Implement pagination for large datasets
- Optimize database queries
- Implement request/response compression
4. Security Considerations
- Validate all input data
- Implement proper authentication and authorization
- Use HTTPS for all communications
- Implement rate limiting
- Sanitize all user input
This comprehensive implementation plan ensures consistent, enterprise-grade development of the AutoStore system using NestJS with TypeScript, following DDD and Clean Architecture principles. The plan provides detailed guidance for developers at all levels while maintaining flexibility for implementation choices.