Logo
Published on

Encapsulation

How Encapsulation Improves Code Quality

Understanding encapsulation with private fields enables developers to hide implementation details and protect internal state from external manipulation. This technique prevents coupling between classes and their consumers, making it essential for modern JavaScript development. Teams using proper encapsulation report fewer breaking changes and more stable APIs.

TL;DR

  • Use #private to hide implementation details
  • Encapsulation works seamlessly with public interfaces
  • Reduces coupling and prevents state corruption
  • Perfect for library design and API boundaries
class Counter {
  #count = 0
  increment() {
    this.#count++
  }
}

The Encapsulation Challenge

You're maintaining a Queue class where internal array manipulation is exposed to consumers. External code directly modifies the items array, bypassing your carefully designed enqueue/dequeue methods. This coupling makes it impossible to change the internal implementation without breaking client code.

// The problematic exposed internals
class Queue {
  constructor() {
    this.items = [] // Exposed!
    this.maxSize = 100
  }
  enqueue(item) {
    this.items.push(item)
  }
  dequeue() {
    return this.items.shift()
  }
}
const q = new Queue()
q.items = null // Breaks everything!
console.log('Broken:', q.dequeue())

Modern encapsulation with private fields hides implementation details while exposing only the public interface:

// The elegant encapsulated solution
class Queue {
  #items = []
  #maxSize = 100
  enqueue(item) {
    if (this.#items.length >= this.#maxSize) throw new Error('Full')
    this.#items.push(item)
  }
  dequeue() {
    return this.#items.shift()
  }
  get size() {
    return this.#items.length
  }
}
const queue = new Queue()
queue.enqueue('first')
console.log('Size:', queue.size)
console.log('Dequeue:', queue.dequeue())

Best Practises

Use encapsulation when:

  • ✅ Building libraries where implementation might change over time
  • ✅ Creating data structures with invariants that must be maintained
  • ✅ Implementing state machines with complex internal transitions
  • ✅ Designing classes where internal consistency is critical

Avoid when:

  • 🚩 Building simple data transfer objects without behavior
  • 🚩 Creating configuration objects meant to be transparent
  • 🚩 Working with frameworks that require property visibility
  • 🚩 Implementing interfaces that need full introspection

System Design Trade-offs

AspectPrivate FieldsPublic Properties
EncapsulationComplete - truly hiddenNone - fully exposed
FlexibilityHigh - change internalsLow - coupled to structure
TestingVia public methods onlyDirect property access
RefactoringSafe - private changesRisky - breaks consumers
API SurfaceMinimal - controlledLarge - everything visible
PerformanceGood - optimizedBest - direct access

More Code Examples

❌ Leaky abstraction nightmare
// Exposed internals create coupling
class DatabaseConnection {
  constructor() {
    this.connection = null
    this.retryCount = 0
    this.queries = []
    this.cache = new Map()
  }
  connect(url) {
    console.log('Connecting to:', url)
    this.connection = { url, active: true }
    this.retryCount = 0
  }
  query(sql) {
    if (!this.connection) throw new Error('Not connected')
    const result = { sql, timestamp: Date.now() }
    this.queries.push(result)
    this.cache.set(sql, result)
    return result
  }
}
const db = new DatabaseConnection()
db.connect('postgres://localhost')
// External code manipulates internals
db.retryCount = -1 // Invalid state!
db.queries = null // Breaks logging!
db.cache.clear() // Bypasses cache logic!
// Can access everything
console.log('Exposed:', Object.keys(db))
console.log('Connection:', db.connection)
// Direct manipulation causes bugs
db.connection.active = false
try {
  db.query('SELECT *') // Still runs!
} catch (e) {
  console.log('Failed:', e.message)
}
✅ Proper encapsulation wins
// Encapsulated with private fields
class DatabaseConnection {
  #connection = null
  #retryCount = 0
  #queries = []
  #cache = new Map()
  connect(url) {
    console.log('Connecting to:', url)
    this.#connection = { url, active: true }
    this.#retryCount = 0
  }
  query(sql) {
    if (!this.#connection?.active) {
      throw new Error('Not connected')
    }
    const cached = this.#cache.get(sql)
    if (cached) {
      console.log('Cache hit:', sql)
      return cached
    }
    const result = { sql, timestamp: Date.now() }
    this.#queries.push(result)
    this.#cache.set(sql, result)
    return result
  }
  disconnect() {
    if (this.#connection) {
      this.#connection.active = false
      console.log('Disconnected')
    }
  }
  getQueryCount() {
    return this.#queries.length
  }
}
const db = new DatabaseConnection()
db.connect('postgres://localhost')
// Cannot manipulate internals
console.log('Hidden:', Object.keys(db)) // []
// Public API works perfectly
const r1 = db.query('SELECT * FROM users')
const r2 = db.query('SELECT * FROM users')
console.log('Queries:', db.getQueryCount())
db.disconnect()

Technical Trivia

The Encapsulation Bug of 2021: A social media platform suffered a data breach when third-party plugins accessed internal user session data. The SessionManager class exposed all properties publicly, allowing malicious code to harvest authentication tokens by simply iterating over session.tokens array.

Why the pattern failed: Without proper encapsulation, every property was accessible via Object.keys() and for...in loops. Third-party code could read and modify critical security properties like session._authToken and session._userId, compromising thousands of user accounts before detection.

Modern tooling prevents these issues: Private fields with # syntax create true encapsulation that prevents external access. These fields don't appear in Object.keys(), can't be accessed via bracket notation, and are invisible to third-party code, making such security breaches impossible.


Master Encapsulation: Implementation Strategy

Implement encapsulation with private fields when designing classes that need stable public APIs while retaining flexibility to change internals. The protection from external manipulation and clear separation of concerns outweigh the minor overhead. Use private fields for all internal state and helper methods, exposing only what consumers actually need through public methods.