Brand Series Module Backend Documentation
Backend architecture, data model, invariants, and caching for catalog brand-series flows.
Brand Series Module - Backend Documentation
1. Backend Scope and Boundaries
Brand Series backend owns:
- brand series CRUD operations (admin surface)
- visible-only listing and slug-based detail (customer/mobile surface)
- auto-slug generation from name with suffix resolution on conflict
- brand and category existence validation for write operations
- read-side caching for customer-facing endpoints
- reorder operation for storefront display sequencing
Brand Series backend does NOT own:
- brand or category management (owned by
BrandModuleandCategoryModulerespectively) - product-to-series linkage (products have a nullable
series_idFK, but product write operations are owned byProductModule) - queue/worker processing (all operations are synchronous)
- outbox event dispatching (no async side effects)
- rate limiting (no module-level throttling)
- status history or audit log (state changes are timestamp-only, no append-only history table)
2. Module Composition (Aggregate + Leaf)
BrandSeriesAdminModule composes:
BrandSeriesAdminController-- HTTP surface for admin CRUDBrandSeriesAdminService-- business logic for admin operations
BrandSeriesCustomerModule composes:
BrandSeriesCustomerController-- HTTP surface for public listing and detailBrandSeriesCustomerService-- business logic with caching layer
Ownership model:
- admin module is a leaf module imported by
BrandAdminModulewhich is composed intoCatalogAdminModule - customer module is a leaf module imported by
BrandCustomerModulewhich is composed intoCatalogCustomerModule - both modules share no service; they operate independently with their own DB and cache dependencies
- mobile surface is composed by
MobileModulewhich re-exports the customer module routes under/api/mobile/
3. Data Model (Drizzle / PostgreSQL)
3.1 Primary table: brand_series
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | serial | PK | Auto-increment primary key |
brand_id | integer | NOT NULL, FK -> brands.id ON DELETE CASCADE | Owning brand |
category_id | integer | NOT NULL, FK -> categories.id ON DELETE CASCADE | Product category for this series |
name | varchar(255) | NOT NULL | Display name |
slug | varchar(255) | NOT NULL, UNIQUE | URL-safe identifier; auto-generated |
description | text | NULLABLE | Optional description |
image_url | varchar(500) | NULLABLE | Optional image URL |
is_active | boolean | NOT NULL, DEFAULT true | Visibility for customer endpoints |
order | integer | NOT NULL, DEFAULT 0 | Display order (lower = first) |
created_at | timestamp with time zone | NOT NULL, DEFAULT now() | Creation timestamp |
updated_at | timestamp with time zone | NOT NULL, DEFAULT now() | Last update timestamp; auto-updated via $onUpdateFn |
3.2 Foreign keys
brand_idreferencesbrands.idwithON DELETE CASCADE-- deleting a brand removes all its seriescategory_idreferencescategories.idwithON DELETE CASCADE-- deleting a category removes all its series- Products link to series via
product.series_id(nullable FK tobrand_series.id) -- this FK is defined in the product schema, not in brand-series
3.3 Indexes
| Index name | Type | Columns | Purpose |
|---|---|---|---|
brand_series_pkey | PK | id | Primary key lookup |
brand_series_slug_uidx | UNIQUE | slug | Slug uniqueness for customer detail lookup |
brand_series_brand_category_uidx | UNIQUE | brand_id, category_id | Enforces one series per (brand, category) pair |
brand_series_brand_id_idx | INDEX | brand_id | Filter by brand in list queries |
brand_series_category_id_idx | INDEX | category_id | Filter by category in list queries |
brand_series_active_order_idx | INDEX | is_active, order | Customer list query: visible series sorted by order |
3.4 Schema source of truth
packages/db/src/schema/catalog/brand-series.ts
3.5 Related tables (not owned by this module)
product-- hasseries_id(nullable FK tobrand_series.id), used byBrandSeriesCustomerServiceto computeproductCountbrand-- FK target forbrand_idcategory-- FK target forcategory_id
4. Runtime Rules and Domain Invariants
4.1 Name uniqueness within brand-category
Series name does not have a standalone unique constraint. Instead, the unique constraint is on the (brand_id, category_id) pair. This means:
- There cannot be two series for the same brand and category combination.
- Different brands can have series with the same name.
- The same brand can have series with the same name if they are assigned to different categories.
- Duplicate attempt returns
BRAND_SERIES_DUPLICATE(HTTP 409).
4.2 Slug generation
Slug is auto-generated from name using the following pipeline:
- Trim whitespace, convert to lowercase.
- Remove all non-alphanumeric characters except hyphens and spaces.
- Replace spaces with hyphens.
- Collapse consecutive hyphens.
- Strip leading/trailing hyphens.
- If result is empty, use
"series"as the base. - Check for slug conflicts in DB:
- If no conflict, use the base slug.
- If conflict exists with a different series id, append
-1,-2, etc. until a unique slug is found. - Slug update during edit skips the current series' own slug.
4.3 Visibility rules
- Customer/mobile endpoints always apply
WHERE is_active = truefilter. - Admin endpoints return all series regardless of
isActive. - Products can be linked to invisible series; the visibility of the series does not affect product visibility.
4.4 Brand and category validation
Before creating or updating a series:
brandIdis validated against thebrandstable. If not found, returnsBRAND_NOT_FOUND.categoryIdis validated against thecategoriestable. If not found, returnsCATEGORY_NOT_FOUND.- Both validations run in parallel via
Promise.all().
4.5 Reorder behavior
- Reorder accepts an array of
{ id, order }objects. - Each item is updated individually via separate UPDATE queries executed in parallel via
Promise.all(). - There is no wrapping transaction; each update is atomic.
- List cache is invalidated after all updates complete.
- No validation is performed that all provided ids exist.
4.6 Cache invalidation on write
createinvalidates the list cache patterncatalog:brand-series:list:*.updateinvalidates list cache + the old slug detail cache. If slug changed, also invalidates the new slug detail cache.deleteinvalidates list cache + the deleted series slug detail cache.reorderinvalidates list cache only (detail cache is unaffected by order changes).
4.7 No async processing
Unlike Order, this module has no:
- queue workers or background jobs
- outbox event dispatching
- async request tracking
- scheduled maintenance tasks
5. Caching Strategy
Read-side keyspaces:
catalog:brand-series:list:*-- customer list responsescatalog:brand-series:detail:slug:*-- customer detail responses
Cache behavior:
- Customer service uses
getOrSetpattern: check cache first, populate on miss. - Admin service does NOT cache reads; all admin queries hit the database directly.
- Cache entries use the TTL specified by
CATALOG_CACHE_TTL_SECONDS. - Fallback chain:
CATALOG_CACHE_TTL_SECONDS->REDIS_CACHE_TTL_SECONDS->300(default).
Customer cache key construction:
catalog:brand-series:list:brand:<brandId>-cat:<categoryId>-page:<page>-size:<size>
catalog:brand-series:detail:slug:<slug>Invalidation:
- Admin write operations use
RedisCacheService.invalidatePattern()for list cache. - Detail cache invalidation uses
RedisCacheService.invalidate()with the exact key.
6. Rate Limiting Strategy
This module does not apply its own rate limiting.
Customer endpoints are public and currently unthrottled. If storefront scraping or API abuse becomes an issue, rate limiting can be added at:
- application level using
@Throttle()decorator from@nestjs/throttler - infrastructure level via reverse proxy (Cloudflare rate limiting, Nginx
limit_req)
Admin endpoints are protected by JWT authentication + RoleGuard + permission checks. Global admin rate limiting defaults apply but no module-specific throttle configuration is set.
7. Error and Resilience Contracts
Representative API/domain errors:
| HTTP | errorCode | Message |
|---|---|---|
| 404 | BRAND_SERIES_NOT_FOUND | Brand series not found. |
| 404 | BRAND_NOT_FOUND | Brand not found. |
| 404 | CATEGORY_NOT_FOUND | Category not found. |
| 409 | BRAND_SERIES_DUPLICATE | A series for this brand and category already exists. |
| 409 | BRAND_SERIES_SLUG_EXISTS | Brand series slug already exists. |
| 403 | - | Forbidden resource. |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests. Please try again later. |
| 503 | ERR_CONFIG_INVALID | Configuration value is missing/invalid. |
Resilience notes:
- No retry or backoff logic (no async processing).
- DB unique constraint violations are caught and mapped to
ConflictException. - DB FK violations cascade on brand/category delete.
- All operations are idempotent except create (which may return duplicate errors).
8. Performance Notes
- Admin and customer list queries use select projections (never
SELECT *). Only the columns present in the response DTO are selected. - List queries use
PaginationUtil.getDrizzleParams()for normalized pagination. - Joins on
brandsandcategoriesareINNER JOIN(both FKs are required/not-null, so every series has a valid brand and category). - Customer list query includes an implicit
is_active = truefilter, and thebrand_series_active_order_idxcomposite index covers this filter + sort. - Customer detail endpoint includes a correlated subquery for
productCount: This is a per-row query that runs once per detail request. For brands with many series, list queries avoid this subquery.SELECT COUNT(*)::int FROM product p WHERE p.series_id = brand_series.id AND p.status = 'published' AND p.is_sellable = true - Cache
getOrSetpattern prevents repeated DB hits for popular series on the storefront.
9. Backend Diagram
10. Release/QA Checklist
- Admin CRUD operations enforce correct
BrandSeries_*permissions. - Create validates brand and category existence before insert.
- Create with duplicate (brandId, categoryId) returns
BRAND_SERIES_DUPLICATE(409). - Slug generation is conflict-safe: creating "Dell Laptops" when slug "dell-laptops" exists produces "dell-laptops-1".
- Update with name change regenerates slug and invalidates old + new detail cache.
- Customer list returns only
isActive = trueseries. - Customer detail returns
productCountmatching published + sellable products only. - Customer detail for inactive series returns 404.
- Mobile-composed routes (
/api/mobile/brand-series/...) resolve correctly. - Reorder updates all items and invalidates list cache.
- Delete cascades are safe: deleting a brand or category cascades to its series.
- Cache TTL is honored; admin writes invalidate customer cache correctly.
- Admin list supports brandId, categoryId, isActive filters independently and in combination.
11. File Map
| Concern | File Path | Purpose |
|---|---|---|
| Admin controller | apps/api/src/modules/catalog/admin/brand/brand-series/brand-series-admin.controller.ts | HTTP surface for admin CRUD operations |
| Admin service | apps/api/src/modules/catalog/admin/brand/brand-series/brand-series-admin.service.ts | Business logic: CRUD, slug generation, cache invalidation |
| Admin module | apps/api/src/modules/catalog/admin/brand/brand-series/brand-series-admin.module.ts | Module composition and DI registration |
| Customer controller | apps/api/src/modules/catalog/customer/brand/brand-series/brand-series-customer.controller.ts | HTTP surface for public listing and detail |
| Customer service | apps/api/src/modules/catalog/customer/brand/brand-series/brand-series-customer.service.ts | Business logic with caching layer and product count query |
| Customer module | apps/api/src/modules/catalog/customer/brand/brand-series/brand-series-customer.module.ts | Module composition and DI registration |
| DTOs (admin) | apps/api/src/modules/catalog/admin/brand/brand-series/dto/ | CreateBrandSeriesDto, UpdateBrandSeriesDto, FetchBrandSeriesAdminDto, ReorderBrandSeriesDto, BrandSeriesAdminResponseDto |
| DTOs (customer) | apps/api/src/modules/catalog/customer/brand/brand-series/dto/ | FetchBrandSeriesDto, BrandSeriesListItemDto, BrandSeriesDetailDto |
| Drizzle schema | packages/db/src/schema/catalog/brand-series.ts | Table definition, indexes, relations |
12. Environment Variables
| Variable | Default | Description |
|---|---|---|
CATALOG_CACHE_TTL_SECONDS | 300 | Cache TTL for customer-facing catalog list/detail endpoints (also used by brand, category modules) |
REDIS_CACHE_TTL_SECONDS | 300 | Fallback cache TTL if CATALOG_CACHE_TTL_SECONDS is not set |
No module-specific rate limiting env vars exist for this module. Admin endpoints rely on global NestJS throttler defaults.
Time fields in this module are stored as timezone-aware values and should be handled as ISO-8601 instants by API consumers.
See Also
- Feature Guide: See Brand Series - Feature List Section 5 (Key Business Rules) for domain invariants.
- API Reference: See Brand Series - API & Integration Guide Section 11 (Endpoint Reference + Payload Cheatsheet) for API contracts.
- DB Schema: See
packages/db/src/schema/catalog/brand-series.tsfor the full Drizzle schema definition. - Brand Module: See
/docs/developer/catalog/brandfor brand-level documentation. - Category Module: See
/docs/developer/catalog/categoryfor category-level documentation.