Loading content...
After extracting nouns from requirements, you face a deceptively simple question for each candidate: Should this be a class or a property of another class?
This decision—entity versus attribute—is one of the most consequential in object-oriented design. Get it wrong, and you either:
Both errors compound over time. An over-modeled system becomes a maze of tiny classes doing almost nothing. An under-modeled system grows rigid as buried concepts need to evolve independently.
The challenge:
There's no universal rule. Address might be an entity in a shipping system but an attribute in a customer database. Price might be a simple number in one context and a complex object with currency, effective dates, and discount rules in another. Context determines the right choice.
By the end of this page, you will have a systematic framework for distinguishing entities from attributes. You'll learn the key criteria that guide this decision, see concrete examples across domains, and develop intuition for when to promote an attribute to an entity or demote an entity to an attribute.
Before distinguishing them, let's define precisely what we mean by entity and attribute in object-oriented design.
Entity (Class):
An entity is a distinct concept in your domain that:
Attribute (Property):
An attribute is a characteristic or quality of an entity that:
| Characteristic | Entity | Attribute |
|---|---|---|
| Identity | Has unique identity (ID) | No independent identity |
| Independence | Exists independently | Exists only as part of entity |
| Lifecycle | Has own lifecycle | Follows owning entity's lifecycle |
| Behavior | Often has methods | Usually just holds data |
| References | Referenced by ID from other entities | Accessed through owning entity |
| Persistence | Has own table/document | Stored within entity's record |
| Complexity | Can be arbitrarily complex | Usually simple/primitive |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Clear entity: Customer// Has identity, lifecycle, behavior, independenceclass Customer { id: string; // Identity name: string; // Attribute: simple property email: string; // Attribute: simple property registeredAt: Date; // Attribute: simple property updateEmail(newEmail: string): void { /* behavior */ } deactivate(): void { /* lifecycle */ }} // Clear attribute: name// No identity of its own, describes Customer, simple string// You'd never ask "What's the ID of this name?" // Ambiguous case: Address// Is this an entity or an attribute? // OPTION A: Address as an attribute (embedded/value object)class CustomerWithAddressAttribute { id: string; name: string; street: string; // Address fields as attributes city: string; state: string; zipCode: string; country: string;} // OPTION B: Address as a separate entityclass Address { id: string; // Has identity street: string; city: string; state: string; zipCode: string; country: string; format(): string { /* behavior */ } validate(): ValidationResult { /* behavior */ }} class CustomerWithAddressEntity { id: string; name: string; addressId: string; // References Address entity // or address: Address; // Embedded reference} // Which is correct? It depends on the context and requirements!When should a candidate noun be promoted from a potential attribute to a full entity? Apply these criteria systematically:
Criterion 1: Identity Requirement
Does the concept need to be uniquely identified independent of its context?
Criterion 2: Shared Reference
Is the concept referenced by multiple entities?
Criterion 3: Independent Lifecycle
Can the concept exist or change independently of other entities?
Criterion 4: Significant Behavior
Does the concept have operations (methods) beyond simple get/set?
Criterion 5: Change Frequency Mismatch
Does this aspect change at a different rate than the rest of the entity?
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
// EXAMPLE 1: Author in a Book System// Applying the criteria to decide: Entity or Attribute? // Criterion 1: Identity Requirement// "I want to see all books by THIS author (ID: 123)"// ✓ Needs identity → suggests Entity // Criterion 2: Shared Reference// Multiple books reference the same author// ✓ Shared across entities → suggests Entity // Criterion 3: Independent Lifecycle// Authors exist before books are published// Authors can be updated (biography) without changing books// ✓ Independent lifecycle → suggests Entity // Criterion 4: Significant Behavior// Authors might have: calculateRoyalties(), getBibliography()// ✓ Has business behavior → suggests Entity // DECISION: Author should be an Entityclass Author { id: string; name: string; biography: string; birthDate: Date; getBibliography(): Book[] { /* ... */ } calculateRoyalties(period: DateRange): Money { /* ... */ }} class Book { id: string; title: string; authorId: string; // References Author entity} // EXAMPLE 2: Page Count in a Book System// Applying the criteria: // Criterion 1: Identity Requirement// "Show me page count #456" - nonsensical// ✗ No identity needed → suggests Attribute // Criterion 2: Shared Reference// Each book has its own page count// ✗ Not shared → suggests Attribute // Criterion 3: Independent Lifecycle// Page count doesn't change independently of book// ✗ No independent lifecycle → suggests Attribute // Criterion 4: Significant Behavior// No operations on page count alone// ✗ No behavior → suggests Attribute // DECISION: Page count should be an Attributeclass Book { id: string; title: string; pageCount: number; // Simple attribute} // EXAMPLE 3: Price in an E-commerce System// This is where it gets interesting! // Simple context: Product catalog display// ✗ No independent identity needed// ✗ Each product has its own price// ✗ Price is just data// → ATTRIBUTE is appropriate class SimpleProduct { id: string; name: string; price: number; // Attribute} // Complex context: Multi-currency, price history, promotions// ✓ Prices have effective dates and need tracking// ✓ Multiple products might share a price tier// ✓ Complex calculation logic// → ENTITY is appropriate class PriceEntity { id: string; amount: number; currency: Currency; effectiveFrom: Date; effectiveTo: Date | null; isActive(): boolean { /* ... */ } convertTo(currency: Currency): PriceEntity { /* ... */ }} class ComplexProduct { id: string; name: string; priceId: string; // References Price entity}The same concept can be an entity in one system and an attribute in another. 'Email' is an attribute in a simple CRM but might be an entity in an email marketing platform where email addresses have verification status, bounce history, and engagement metrics. Always consider your specific domain requirements.
Between entities and simple attributes lies a powerful concept: the Value Object. Value Objects bundle related attributes together with behavior, but unlike entities, they have no identity.
Value Object characteristics:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
// VALUE OBJECT: Money// Groups amount and currency, has behavior, no identityclass Money { private constructor( readonly amount: number, readonly currency: Currency ) { if (amount < 0) throw new Error("Amount cannot be negative"); } static of(amount: number, currency: Currency): Money { return new Money(amount, currency); } // Behavior: arithmetic operations add(other: Money): Money { if (this.currency !== other.currency) { throw new Error("Cannot add different currencies"); } return Money.of(this.amount + other.amount, this.currency); } multiply(factor: number): Money { return Money.of(this.amount * factor, this.currency); } // Behavior: formatting format(): string { return `${this.currency} ${this.amount.toFixed(2)}`; } // Equality by value, not identity equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency; }} // Usage: Replace, don't mutatelet price = Money.of(100, Currency.USD);price = price.multiply(1.1); // Creates new Money, doesn't mutate // VALUE OBJECT: Address// More complex than primitives, but no identityclass Address { private constructor( readonly street: string, readonly city: string, readonly state: string, readonly zipCode: string, readonly country: string ) { this.validate(); } static create(props: AddressProps): Address { return new Address( props.street, props.city, props.state, props.zipCode, props.country ); } private validate(): void { if (!this.street || !this.city || !this.country) { throw new Error("Street, city, and country are required"); } // Additional validation... } format(): string { return `${this.street}\n${this.city}, ${this.state} ${this.zipCode}\n${this.country}`; } isInternational(homeCountry: string): boolean { return this.country !== homeCountry; } equals(other: Address): boolean { return this.street === other.street && this.city === other.city && this.state === other.state && this.zipCode === other.zipCode && this.country === other.country; }} // VALUE OBJECT: DateRange// Encapsulates start-end logicclass DateRange { private constructor( readonly start: Date, readonly end: Date ) { if (end < start) { throw new Error("End date must be after start date"); } } static create(start: Date, end: Date): DateRange { return new DateRange(start, end); } contains(date: Date): boolean { return date >= this.start && date <= this.end; } overlaps(other: DateRange): boolean { return this.start <= other.end && this.end >= other.start; } getDurationInDays(): number { const msPerDay = 24 * 60 * 60 * 1000; return Math.ceil((this.end.getTime() - this.start.getTime()) / msPerDay); }}| Modeling Choice | Use When | Examples |
|---|---|---|
| Primitive Attribute | Simple, single value; no invariants or behavior | name (string), age (number), isActive (boolean) |
| Value Object | Multiple related values; has behavior/invariants; no identity | Money, Address, DateRange, Email, PhoneNumber |
| Entity | Needs identity; independent lifecycle; referenced by multiple entities | Customer, Order, Product, Invoice |
Primitive obsession is an anti-pattern where complex concepts are represented as simple primitives (strings, numbers). Value Objects cure this by encapsulating domain concepts with their behavior and validation. An Email Value Object validates format; a Money Value Object handles currency conversion. Don't model everything as strings and ints.
Certain concepts appear repeatedly across domains, and recognizing patterns helps you make decisions faster. Here's guidance for commonly debated concepts:
| Concept | Usually Entity When... | Usually Attribute/VO When... |
|---|---|---|
| Address | Shared (shipping addresses reused), has delivery preferences | Embedded per customer, no sharing |
| Phone Number | Part of contact management system, has verification | Simple string for contact purposes |
| Email marketing system, needs bounce/engagement tracking | Just a contact field on User | |
| Price | Price history needed, multi-currency, tiered pricing | Simple current price per product |
| Date/Time | Almost never; use Value Objects (DateTime, DateRange) | Primitive Date is usually fine |
| Location (lat/long) | Geospatial queries, place information attached | Just coordinates for one-off storage |
| Comment | Has replies, edits, likes; referenced independently | Simple text note embedded in parent |
| Tag | Tag library with descriptions, shared across items | Just a list of strings on an item |
| Category | Has hierarchy, metadata, used for navigation | Just a string label |
| Status | Has transitions, history, attached metadata | Simple enum value |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// EXAMPLE: Category - When is it an Entity vs Attribute? // SCENARIO A: E-commerce with category navigation// Categories have hierarchy, descriptions, images// Products belong to category; users browse by category// → Entity class Category { id: string; name: string; description: string; parentId: string | null; // Hierarchy imageUrl: string; seoMetadata: SEOMetadata; getAncestors(): Category[] { /* ... */ } getSubcategories(): Category[] { /* ... */ } getBreadcrumb(): string { /* ... */ }} class Product { id: string; name: string; categoryId: string; // References Category entity} // SCENARIO B: Blog post with simple tags// Tags are just labels, no hierarchy or metadata// → Attribute (array of strings) class BlogPost { id: string; title: string; content: string; tags: string[]; // Simple attribute array} // EXAMPLE: Status - Enum vs Entity // SCENARIO A: Simple order status// Fixed set of statuses, no transitions tracked// → Enum enum OrderStatus { PENDING = "PENDING", CONFIRMED = "CONFIRMED", SHIPPED = "SHIPPED", DELIVERED = "DELIVERED", CANCELLED = "CANCELLED"} class SimpleOrder { id: string; status: OrderStatus; // Enum attribute} // SCENARIO B: Workflow with status transitions// Status changes tracked with timestamps and actors// Transition rules enforced// → Embedded Value Object or Related Entity class StatusChange { timestamp: Date; fromStatus: OrderStatus; toStatus: OrderStatus; changedBy: string; reason?: string;} class WorkflowOrder { id: string; currentStatus: OrderStatus; statusHistory: StatusChange[]; // Tracks all transitions transitionTo(newStatus: OrderStatus, actor: string, reason?: string): void { // Validates transition is allowed // Records in history }}Design decisions aren't permanent. As you develop the system, certain patterns emerge that suggest you've over-modeled or under-modeled. Recognizing these signs early lets you refactor before complexity compounds.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
// SIGN OF OVER-MODELING: Anemic entity with no behavior // ❌ This entity is over-modeled - it's just dataclass CustomerPreferences { id: string; customerId: string; emailNotifications: boolean; smsNotifications: boolean; theme: 'light' | 'dark'; language: string; // No methods beyond getters/setters} // ✅ Should be embedded attributes or Value Objectclass Customer { id: string; name: string; // Embedded as Value Object preferences: { emailNotifications: boolean; smsNotifications: boolean; theme: 'light' | 'dark'; language: string; };} // SIGN OF UNDER-MODELING: Repeated attribute groups // ❌ Same address fields duplicatedclass Customer { id: string; name: string; // Billing address billingStreet: string; billingCity: string; billingState: string; billingZip: string; billingCountry: string; // Shipping address shippingStreet: string; shippingCity: string; shippingState: string; shippingZip: string; shippingCountry: string;} // ✅ Extract to Value Object (or Entity if shared)class Address { readonly street: string; readonly city: string; readonly state: string; readonly zip: string; readonly country: string; format(): string { /* ... */ } validate(): void { /* ... */ }} class CustomerFixed { id: string; name: string; billingAddress: Address; shippingAddress: Address;} // SIGN OF UNDER-MODELING: Complex validation scattered // ❌ Phone validation logic scatteredclass Contact { phone: string; // Just a string validatePhone(): boolean { // Complex regex, country code handling, etc. // This logic doesn't belong here! }} // ✅ Extract to Value Object with encapsulated validationclass PhoneNumber { private constructor( readonly countryCode: string, readonly number: string ) {} static parse(input: string): PhoneNumber { // Parsing and validation logic here const { countryCode, number } = this.parseAndValidate(input); return new PhoneNumber(countryCode, number); } private static parseAndValidate(input: string): { countryCode: string; number: string } { // Complex validation encapsulated } format(): string { return `+${this.countryCode} ${this.number}`; } isInternational(homeCountry: string): boolean { return this.countryCode !== homeCountry; }} class ContactFixed { phone: PhoneNumber; // Value Object, not primitive}If you discover you've made the wrong entity/attribute choice, that's not failure—it's learning. Requirements evolve, and initial assumptions prove incorrect. The key is recognizing the signs early and refactoring before the wrong abstraction becomes deeply embedded in your codebase.
Domain-Driven Design (DDD) provides precise terminology that clarifies the entity/attribute distinction:
DDD Terminology:
| DDD Concept | Definition | Equivalent |
|---|---|---|
| Entity | Object defined by identity, not attributes | Class with ID |
| Value Object | Object defined by attributes, immutable, no identity | Complex attribute |
| Aggregate | Cluster of entities/VOs with a root entity | Transactional boundary |
| Aggregate Root | Entry point to aggregate, only external reference point | Main entity |
DDD emphasizes that the distinction isn't just about code—it reflects how domain experts think about concepts.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
// DDD-STYLE MODELING: Order Aggregate // AGGREGATE ROOT: Order// - Has identity (orderId)// - Entry point for all operations// - Only reference point from outside the aggregateclass Order { readonly orderId: OrderId; // Value Object for ID private status: OrderStatus; private lineItems: LineItem[]; // Entities within aggregate private shippingAddress: Address; // Value Object private billingAddress: Address; // Value Object readonly customerId: CustomerId; // Reference to different aggregate // All operations go through aggregate root addItem(product: ProductId, quantity: number, price: Money): void { this.lineItems.push(new LineItem(product, quantity, price)); } removeItem(lineItemId: LineItemId): void { this.lineItems = this.lineItems.filter(item => !item.id.equals(lineItemId)); } calculateTotal(): Money { return this.lineItems.reduce( (sum, item) => sum.add(item.subtotal()), Money.zero(Currency.USD) ); } ship(carrier: string, trackingNumber: string): void { if (this.status !== OrderStatus.PAID) { throw new Error("Cannot ship unpaid order"); } this.status = OrderStatus.SHIPPED; // Domain event would be raised here }} // ENTITY WITHIN AGGREGATE: LineItem// - Has identity (within the aggregate)// - Cannot be referenced from outside Order// - Lifecycle bound to Orderclass LineItem { readonly id: LineItemId; readonly productId: ProductId; private quantity: number; private unitPrice: Money; subtotal(): Money { return this.unitPrice.multiply(this.quantity); } updateQuantity(newQuantity: number): void { if (newQuantity < 1) throw new Error("Quantity must be positive"); this.quantity = newQuantity; }} // VALUE OBJECTS: Immutable, equality by valueclass OrderId { constructor(readonly value: string) { if (!value || value.length === 0) { throw new Error("OrderId cannot be empty"); } } equals(other: OrderId): boolean { return this.value === other.value; }} class Money { private constructor( readonly amount: number, readonly currency: Currency ) {} static of(amount: number, currency: Currency): Money { return new Money(amount, currency); } static zero(currency: Currency): Money { return new Money(0, currency); } add(other: Money): Money { this.assertSameCurrency(other); return new Money(this.amount + other.amount, this.currency); } multiply(factor: number): Money { return new Money(this.amount * factor, this.currency); } private assertSameCurrency(other: Money): void { if (this.currency !== other.currency) { throw new Error("Currency mismatch"); } }} class Address { constructor( readonly street: string, readonly city: string, readonly state: string, readonly zipCode: string, readonly country: string ) { // Validation in constructor } // Value Objects are compared by value equals(other: Address): boolean { return this.street === other.street && this.city === other.city && this.state === other.state && this.zipCode === other.zipCode && this.country === other.country; }}A key DDD insight: Aggregate boundaries are transactional boundaries. Everything inside an aggregate should be modifiable in a single transaction. If you find yourself needing to modify two 'entities' separately in different transactions, they belong to different aggregates.
Let's practice making entity/attribute decisions with a realistic scenario. Consider an Ride-Sharing Application with these requirements:
Analyzing key concepts:
| Concept | Analysis | Decision |
|---|---|---|
| Rider | Has ID, lifecycle, behavior (request ride, rate driver) | ✅ Entity |
| Driver | Has ID, lifecycle, behavior (accept ride, set availability) | ✅ Entity |
| Ride/Trip | Has ID, lifecycle (requested→accepted→completed), state transitions | ✅ Entity |
| Location (lat/lng) | Used by many entities, no independent identity | Value Object |
| Pickup Location | Just a Location at a moment in time | Attribute (Location VO) on Ride |
| Vehicle | Has ID (license plate), could be swapped, has own details | ⚠️ Entity OR embedded—depends on complexity |
| Rating | Has ID, might be reviewed/responded to, might be standalone record | ⚠️ Entity if featured; Attribute if simple 1-5 |
| Payment | Has ID, has lifecycle (pending→completed), transaction record | ✅ Entity |
| Favorite Location | Saved for rider, has name ('Home'), might be shared | ⚠️ Likely Entity (has name, pattern of use) |
| Service Area | Complex geometry, might be shared/templated | ⚠️ Value Object or Entity based on complexity |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
// Ride-Sharing Domain Model // VALUE OBJECT: Locationclass Location { constructor( readonly latitude: number, readonly longitude: number ) { this.validate(); } private validate(): void { if (this.latitude < -90 || this.latitude > 90) { throw new Error("Invalid latitude"); } if (this.longitude < -180 || this.longitude > 180) { throw new Error("Invalid longitude"); } } distanceTo(other: Location): number { // Haversine formula return calculateDistance(this, other); } equals(other: Location): boolean { return this.latitude === other.latitude && this.longitude === other.longitude; }} // ENTITY: Riderclass Rider { readonly id: RiderId; private name: string; private phone: PhoneNumber; // Value Object private email: Email; // Value Object private paymentMethods: PaymentMethod[]; private favoriteLocations: FavoriteLocation[]; // Entities owned by Rider requestRide(pickup: Location, dropoff: Location): RideRequest { return new RideRequest(this.id, pickup, dropoff); } rateDriver(ride: Ride, rating: number, comment?: string): Rating { return new Rating(ride.id, this.id, ride.driverId, rating, comment); } addFavoriteLocation(name: string, location: Location): void { this.favoriteLocations.push(new FavoriteLocation(name, location)); }} // ENTITY: FavoriteLocation (belongs to Rider aggregate)class FavoriteLocation { readonly id: FavoriteLocationId; readonly name: string; // "Home", "Work", etc. readonly location: Location; // Value Object constructor(name: string, location: Location) { this.id = FavoriteLocationId.generate(); this.name = name; this.location = location; }} // ENTITY: Driverclass Driver { readonly id: DriverId; private name: string; private phone: PhoneNumber; private email: Email; private vehicle: Vehicle; // Entity or Value Object? private serviceArea: ServiceArea; // Value Object private status: DriverStatus; isAvailable(): boolean { return this.status === DriverStatus.ONLINE; } acceptRide(request: RideRequest): Ride { if (!this.isAvailable()) { throw new Error("Driver is not available"); } return Ride.create(request, this.id); }} // DECISION: Vehicle as embedded Value Object (simple model)// If vehicles need separate management, tracking, insurance → Entityclass Vehicle { constructor( readonly make: string, readonly model: string, readonly year: number, readonly color: string, readonly licensePlate: string ) {} getDisplayName(): string { return `${this.color} ${this.year} ${this.make} ${this.model}`; }} // ENTITY: Ride (core transaction entity)class Ride { readonly id: RideId; readonly riderId: RiderId; readonly driverId: DriverId; readonly pickupLocation: Location; // Value Object readonly dropoffLocation: Location; // Value Object private status: RideStatus; private startTime: Date | null; private endTime: Date | null; static create(request: RideRequest, driverId: DriverId): Ride { // Factory method } start(): void { this.status = RideStatus.IN_PROGRESS; this.startTime = new Date(); } complete(): void { this.status = RideStatus.COMPLETED; this.endTime = new Date(); } calculateFare(): Money { // Based on distance and time }} // ENTITY: Rating (if we need to manage/respond to ratings)// Could be Value Object embedded in Ride if ratings are simpleclass Rating { readonly id: RatingId; readonly rideId: RideId; readonly riderId: RiderId; readonly driverId: DriverId; readonly score: number; // 1-5 readonly comment: string | null; readonly createdAt: Date; private response: string | null; constructor( rideId: RideId, riderId: RiderId, driverId: DriverId, score: number, comment?: string ) { if (score < 1 || score > 5) { throw new Error("Rating must be 1-5"); } this.id = RatingId.generate(); this.rideId = rideId; this.riderId = riderId; this.driverId = driverId; this.score = score; this.comment = comment ?? null; this.createdAt = new Date(); this.response = null; } addDriverResponse(response: string): void { this.response = response; }}You now have a framework for one of the most important decisions in object-oriented design: determining whether a concept should be modeled as an entity (class) or an attribute (property).
What's next:
Not all entities are equally important. Some are essential to fulfilling core requirements; others are nice-to-have extensions. The next page explores how to distinguish essential from optional entities—a skill that helps you prioritize your design effort and avoid scope creep.
You're now equipped to make entity versus attribute decisions systematically. This skill prevents both over-engineering (too many tiny classes) and under-modeling (buried concepts that should be explicit). Next, we'll learn to distinguish essential entities from optional ones to focus our design efforts.