Loading learning content...
In the previous page, we established that entities are the identifiable "things" in your system—the nouns with continuity and meaning. But an entity without description is like a noun without adjectives: grammatically correct but informationally useless.
A User entity exists, but what do we know about it? What's their email? Their name? When did they join? Are they verified? What plan are they on? These descriptive elements are what we call attributes—the properties that give entities their characteristics and make them useful for business logic.
By the end of this page, you will understand what attributes are, how to identify which attributes an entity needs, the critical distinction between essential and incidental attributes, and how to design attribute sets that are complete yet focused. You'll develop the skill to model entities with exactly the right level of detail.
Attributes are the properties that describe an entity's characteristics and state. They answer questions about the entity:
name attributecreatedAt attributestatus attributeprice attributeisActive attributeIn code, attributes typically manifest as instance variables (fields) of a class. They hold the data that makes one instance distinguishable from another instance of the same type.
12345678910111213141516171819202122232425262728293031323334353637383940
class Product { // Identity private readonly id: string; // Descriptive attributes private name: string; private description: string; private category: Category; // Pricing attributes private price: Money; private discount: Percentage; // Inventory attributes private stockQuantity: number; private reorderThreshold: number; // State attributes private status: ProductStatus; private isVisible: boolean; // Temporal attributes private readonly createdAt: Date; private updatedAt: Date; constructor( id: string, name: string, price: Money, category: Category ) { this.id = id; this.name = name; this.price = price; this.category = category; this.createdAt = new Date(); this.updatedAt = new Date(); // ... defaults for other fields }}While we often discuss the entity's ID alongside attributes, identity is conceptually separate. Identity determines WHICH entity something is; attributes describe WHAT the entity is like. The ID could be considered a special attribute, but it's useful to keep the distinction clear in your mental model.
Not all attributes are alike. Understanding the different categories helps you design complete, well-organized entities. Let's examine the major categories:
| Category | Purpose | Examples | Design Considerations |
|---|---|---|---|
| Identifying | Distinguish instances; may be business-meaningful | ID, SKU, email, order number | Immutable after creation; must be unique within scope |
| Descriptive | Core characteristics of the entity | Name, title, description, bio | Often user-facing; consider localization |
| Categorical | Classify or type the entity | Category, type, role, tier | Use enums or reference entities; validates against fixed lists |
| Quantitative | Numeric measurements or amounts | Price, quantity, balance, rating | Type carefully (Money vs float); handle precision |
| Temporal | Time-related tracking | createdAt, updatedAt, expiresAt, birthDate | Consider timezone; some are immutable (createdAt) |
| State | Current status in lifecycle | status, isActive, isVerified, phase | Often governs allowed operations; model state machines |
| Relational | References to other entities | userId, categoryId, parentId | ID references for loose coupling; consider cascading |
| Computed/Derived | Calculated from other attributes | fullName, age, totalPrice | Don't store if easily computed; cache if expensive |
Why categorization matters:
Understanding attribute categories helps you:
Not every entity needs every category of attribute. A simple value-like entity might only have descriptive and quantitative attributes. An aggregate root might have all categories. The categories are a checklist to prompt completeness, not a required structure.
This is one of the most important distinctions in attribute modeling. Essential attributes are fundamental to what the entity IS. Incidental attributes are nice-to-have details that could be absent or different without changing the entity's fundamental nature.
The distinction matters for:
The Essential Attribute Test:
For each attribute, ask yourself:
Can the entity exist without it? If the entity can be created and function meaningfully without this attribute, it's likely incidental.
Does removing it change what the entity IS? A Product without a price isn't really a product in commerce terms. A User without a bio is still a user.
Is it required by the core use cases? Essential attributes are needed for the primary operations. Incidental attributes support secondary features.
Would a domain expert consider it fundamental? Ask someone who works in the domain—is this attribute central to understanding the concept?
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
class User { // === ESSENTIAL ATTRIBUTES === // Without these, it's not a valid user private readonly id: string; // Must have identity private email: string; // Primary login credential private passwordHash: string; // Required for authentication private readonly createdAt: Date; // When the account was created private status: UserStatus; // Account state (active/suspended) // === INCIDENTAL ATTRIBUTES === // Entity is valid without these; they enhance but don't define private displayName?: string; // Optional; can use email as fallback private avatarUrl?: string; // Nice to have; default avatar fine private bio?: string; // Completely optional profile info private phoneNumber?: string; // Optional second factor private timezone?: string; // Defaults to UTC if not set private locale?: string; // Defaults to en-US private lastLoginAt?: Date; // Tracking, not existence constructor(id: string, email: string, passwordHash: string) { // Essential attributes are REQUIRED in constructor this.id = id; this.email = email; this.passwordHash = passwordHash; this.createdAt = new Date(); this.status = UserStatus.Active; // Incidental attributes have sensible defaults or are undefined } // Essential operations work with essential attributes authenticate(password: string): boolean { return this.verifyPassword(password) && this.status === UserStatus.Active; } // Incidental attributes can be set later updateProfile(updates: Partial<UserProfile>): void { if (updates.displayName !== undefined) { this.displayName = updates.displayName; } if (updates.bio !== undefined) { this.bio = updates.bio; } // ... }}Incidental attributes are often very important for user experience and business value. A profile picture makes users more engaged. A gift message makes an order special. 'Incidental' only means the entity can exist and function at a basic level without them—not that they're unimportant to your product.
Choosing the right types for attributes is crucial for correctness, safety, and clarity. Poor typing leads to bugs, confusion, and defensive code scattered throughout your codebase.
Let's explore best practices for attribute typing:
Primitive Obsession: The Anti-Pattern
One of the most common mistakes is using primitive types (string, number, boolean) for everything. This leads to:
price: number doesn't tell you the currency or precisionuserId and orderId both typed as string compiles fine123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
// ❌ PRIMITIVE OBSESSION — Easy to make mistakesclass WeakOrder { customerId: string; // Is this a UUID? An email? Who knows amount: number; // In dollars? Cents? Bitcoin? email: string; // Could be "not_an_email" discount: number; // 0.1 means 10%? Or $0.10?} // Bugs are easy:const order = new WeakOrder();order.customerId = order.email; // Compiles! Both stringsorder.amount = -500; // Compiles! Just a numberorder.discount = 150; // 150% off? Compiles fine! // ✅ STRONG TYPING — Type system catches mistakes // Value Objects that validate and encapsulateclass CustomerId { private readonly value: string; constructor(value: string) { if (!this.isValidUUID(value)) { throw new InvalidCustomerIdError(value); } this.value = value; } private isValidUUID(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i .test(value); } toString(): string { return this.value; }} class Money { constructor( private readonly amount: number, private readonly currency: Currency ) { if (amount < 0) { throw new NegativeAmountError(amount); } } add(other: Money): Money { if (!this.currency.equals(other.currency)) { throw new CurrencyMismatchError(this.currency, other.currency); } return new Money(this.amount + other.amount, this.currency); }} class EmailAddress { private readonly value: string; constructor(value: string) { if (!this.isValidEmail(value)) { throw new InvalidEmailError(value); } this.value = value.toLowerCase(); // Normalize } private isValidEmail(value: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); }} class Percentage { private readonly value: number; // 0 to 100 constructor(value: number) { if (value < 0 || value > 100) { throw new InvalidPercentageError(value); } this.value = value; } applyTo(amount: Money): Money { return amount.multiply(this.value / 100); }} // Now the Order is type-safeclass StrongOrder { customerId: CustomerId; // Can't be confused with other IDs amount: Money; // Currency is explicit, can't be negative customerEmail: EmailAddress; // Guaranteed valid format discount: Percentage; // 0-100, can't be 150%}When to Create Custom Types:
Not every attribute needs its own type. Create custom types (also called value objects) when:
You don't need to create custom types for everything upfront. Start with primitives for simple cases. When bugs appear because of primitive confusion or scattered validation, that's the signal to introduce a value object. Pragmatic iteration beats premature abstraction.
How do you know if you've captured all the right attributes for an entity? How do you avoid both over-specification (too many attributes) and under-specification (missing critical ones)?
Here's a systematic approach to designing complete attribute sets:
Worked Example: Designing Order Attributes
Let's design the attributes for an Order entity in an e-commerce system.
Use cases:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
class Order { // === IDENTIFICATION === private readonly id: OrderId; private readonly orderNumber: OrderNumber; // Human-readable: ORD-2024-00123 // === CORE RELATIONSHIPS === private readonly customerId: CustomerId; private readonly items: OrderItem[]; // === FINANCIAL === private readonly subtotal: Money; private readonly taxAmount: Money; private readonly shippingCost: Money; private readonly discountApplied: Money; private readonly total: Money; private paymentId?: PaymentId; private paymentStatus: PaymentStatus; // === SHIPPING === private shippingAddress: Address; // Snapshot at order time private billingAddress: Address; private shippingMethod: ShippingMethod; private trackingNumber?: string; private estimatedDelivery?: Date; // === LIFECYCLE === private status: OrderStatus; // Draft, Confirmed, Paid, Shipped, Delivered, Cancelled private readonly createdAt: Date; private updatedAt: Date; private confirmedAt?: Date; private shippedAt?: Date; private deliveredAt?: Date; private cancelledAt?: Date; // === OPERATIONAL === private priority: OrderPriority; private notes: OrderNote[]; // Internal customer service notes private giftMessage?: string; // Customer-facing // === AUDIT === private readonly createdBy?: UserId; // If created by staff on behalf private lastModifiedBy?: UserId; private version: number; // Optimistic locking}Notice we store subtotal, taxAmount, shippingCost, and total as snapshots. In many systems, you might compute total from items. But for orders, we snapshot because prices/taxes may change after placement. The rule: don't store what you can compute UNLESS there's a business reason to preserve the snapshot.
Attributes don't exist in isolation—they must satisfy constraints and maintain consistency with each other. These constraints are called invariants: conditions that must always be true for the entity to be in a valid state.
There are three levels of attribute validation:
| Level | What It Validates | Where It's Enforced | Examples |
|---|---|---|---|
| Type-Level | Valid format/value for a single attribute | In the type/value object | Email format, positive amounts, valid UUID |
| Attribute-Level | Valid value for this attribute in this entity | In setters or entity methods | Username length 3-50, description max 1000 chars |
| Entity-Level | Consistency between multiple attributes | In entity methods, especially state transitions | endDate > startDate, total = sum(items), status transitions |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// Type-level validation: EmailAddressclass EmailAddress { private readonly value: string; constructor(value: string) { // Validates email format once, at construction if (!this.isValidFormat(value)) { throw new InvalidEmailError(value); } this.value = value.toLowerCase(); } private isValidFormat(value: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); }} // Entity with attribute-level and entity-level validationclass Event { private readonly id: EventId; private title: string; private description: string; private startTime: Date; private endTime: Date; private maxAttendees: number; private attendees: UserId[] = []; private status: EventStatus = EventStatus.Draft; constructor(id: EventId, title: string, start: Date, end: Date) { this.id = id; this.setTitle(title); // Attribute-level validation this.setTimeRange(start, end); // Entity-level validation } // Attribute-level: validates single attribute constraints private setTitle(title: string): void { if (title.length < 3 || title.length > 200) { throw new ValidationError("Title must be 3-200 characters"); } this.title = title.trim(); } // Entity-level: validates cross-attribute constraints private setTimeRange(start: Date, end: Date): void { if (end <= start) { throw new ValidationError("End time must be after start time"); } if (start < new Date()) { throw new ValidationError("Cannot create events in the past"); } this.startTime = start; this.endTime = end; } // Entity-level: validates state + capacity registerAttendee(userId: UserId): void { // State constraint if (this.status !== EventStatus.Open) { throw new InvalidOperationError("Event is not open for registration"); } // Capacity constraint if (this.attendees.length >= this.maxAttendees) { throw new CapacityExceededError(this.id); } // Uniqueness constraint if (this.attendees.some(a => a.equals(userId))) { throw new AlreadyRegisteredError(userId, this.id); } this.attendees.push(userId); } // Entity invariant: expose computed property get spotsRemaining(): number { return Math.max(0, this.maxAttendees - this.attendees.length); }}Validate at the boundary. When invalid data enters your system, reject it immediately with a clear error. Don't let it propagate deeper where it causes confusing bugs. The entity's job is to protect its invariants—it should be impossible to create an entity in an invalid state.
Over years of software development, certain attribute design mistakes appear repeatedly. Learning to recognize these patterns helps you avoid them:
status: string invites typos; status: OrderStatus doesn't.isActive && isPaused && isDeleted. Use a single state enum instead.fullName alongside firstName and lastName, creating sync bugs. Store source data; compute derivations.value that's sometimes a dollar amount and sometimes a percentage.distance: number — in meters? feet? miles? Always encode units explicitly or use typed values.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// ❌ ANTI-PATTERN: Multiple boolean flagsclass BadSubscription { isActive: boolean; isPaused: boolean; isCancelled: boolean; isTrialing: boolean; isPastDue: boolean; // What does isActive=true, isPaused=true, isCancelled=true mean?? // How many states is that? 2^5 = 32 theoretical combinations // Most are invalid but not prevented} // ✅ CORRECT: Single state enumenum SubscriptionStatus { Trialing = "TRIALING", Active = "ACTIVE", PastDue = "PAST_DUE", // Still active but payment failed Paused = "PAUSED", Cancelled = "CANCELLED", Expired = "EXPIRED",} class Subscription { private status: SubscriptionStatus; // Clear state, clear transitions canPause(): boolean { return this.status === SubscriptionStatus.Active; } pause(): void { if (!this.canPause()) { throw new InvalidStateTransitionError(this.status, "pause"); } this.status = SubscriptionStatus.Paused; } // Status-dependent behavior is clean get hasAccess(): boolean { return [ SubscriptionStatus.Trialing, SubscriptionStatus.Active, SubscriptionStatus.PastDue, // Grace period ].includes(this.status); }}Sometimes storing 'redundant' data is the right choice. Order totals are snapshots, not computations. Denormalized data for read performance is intentional. The anti-pattern is ACCIDENTAL redundancy where you store computed values without realizing you'll need to keep them in sync.
We've explored attributes as the descriptive properties that give entities their characteristics. Let's consolidate the key takeaways:
What's Next:
We've covered what entities ARE (identity) and what they're LIKE (attributes). But entities aren't just passive data containers—they DO things. The next page explores behaviors: the methods and operations that entities can perform, how to design cohesive method sets, and how behaviors interact with state and invariants.
You now understand how to design attributes that are complete, type-safe, and well-validated. You can distinguish essential from incidental attributes and apply appropriate typing strategies. Next, we'll explore behaviors—the actions that bring your entities to life.