Skip to main content

Designing Wrapper Classes for LWC-Apex Integration

When building Lightning Web Components backed by Apex controllers, the shape of data in the database rarely matches what the UI needs. A wrapper class (also called a Data Transfer Object or DTO) bridges that gap by transforming raw SObject data into a structure optimized for display and interaction.

Why Separate the DB Shape from the UI Shape

Consider an invoice line record. The database stores Quantity__c, Billed_Quantity__c, and Credited_Quantity__c as separate fields. The UI needs to display availableQuantity -- a calculated value that does not exist on the record. Rather than pushing calculation logic into JavaScript, a wrapper class centralizes it in Apex where it can be tested, reused, and kept consistent.

Common reasons to use wrappers:

  • Calculated fields -- derived values like remaining quantity or percentage complete
  • Aggregated data -- rolling up child records into a parent summary
  • Display formatting -- combining fields or converting data types for UI consumption
  • Flattening relationships -- pulling parent/child fields into a single flat structure
  • Selection state -- adding isSelected or isExpanded properties for UI interaction

The @AuraEnabled Requirement

Every property on a wrapper class that needs to be visible to an LWC must be annotated with @AuraEnabled. This is the most common gotcha -- adding a new calculated property but forgetting the annotation, which results in the field being silently excluded from the serialized response.

public class InvoiceLineWrapper {
@AuraEnabled public Id recordId;
@AuraEnabled public String productName;
@AuraEnabled public Decimal quantity;
@AuraEnabled public Decimal billedQuantity;
@AuraEnabled public Decimal creditedQuantity;
@AuraEnabled public Decimal availableQuantity; // calculated
@AuraEnabled public String statusBadge; // derived for UI

public InvoiceLineWrapper(InvoiceLine__c line) {
this.recordId = line.Id;
this.productName = line.Product__r?.Name;
this.quantity = line.Quantity__c ?? 0;
this.billedQuantity = line.Billed_Quantity__c ?? 0;
this.creditedQuantity = line.Credited_Quantity__c ?? 0;
this.availableQuantity = this.quantity - this.billedQuantity - this.creditedQuantity;
this.statusBadge = this.availableQuantity > 0 ? 'Open' : 'Fully Processed';
}
}

Nested Wrappers for Complex Payloads

For operations that require structured input/output (like a credit memo submission), use nested wrappers to define clear request and response contracts:

public class CreditMemoRequest {
@AuraEnabled public Id invoiceId;
@AuraEnabled public String reason;
@AuraEnabled public List<CreditLineRequest> lines;

public class CreditLineRequest {
@AuraEnabled public Id invoiceLineId;
@AuraEnabled public Decimal creditQuantity;
@AuraEnabled public Decimal creditAmount;
}
}

public class CreditMemoResponse {
@AuraEnabled public Boolean success;
@AuraEnabled public String message;
@AuraEnabled public Id creditMemoId;
@AuraEnabled public List<String> warnings;
}

The LWC sends a CreditMemoRequest and receives a CreditMemoResponse, creating a clean, typed API boundary.

Inner Classes vs. Separate Classes

ApproachWhen to use
Inner classWrapper is tightly coupled to one controller and unlikely to be reused
Separate classWrapper is shared across multiple controllers or represents a domain concept

Inner classes keep related code together and reduce file count, but separate classes are easier to import and reference across the codebase.

Common Gotchas

Null serialization. Apex serializes null values as null in JSON. If your LWC JavaScript does not account for this, you may see "undefined" or errors when accessing nested properties. Use the null-coalescing operator (??) in the constructor to set sensible defaults.

Forgetting @AuraEnabled on derived fields. The compiler will not warn you. The property simply will not appear in the LWC. If a field is "missing" on the JavaScript side, check the annotation first.

Mutable state. Wrapper properties marked @AuraEnabled are publicly accessible. If you need immutability, use { get; private set; } -- but note this only protects against Apex-side mutation, not JavaScript-side.

Large collections. Serializing thousands of wrapper objects can hit heap size limits. Use pagination or lazy loading for large datasets.

Using Wrappers in LWC

On the JavaScript side, the wrapper arrives as a plain object:

import getInvoiceLines from '@salesforce/apex/InvoiceController.getInvoiceLines';

export default class InvoiceLineTable extends LightningElement {
lines = [];

async connectedCallback() {
try {
this.lines = await getInvoiceLines({ invoiceId: this.recordId });
// Each element has: recordId, productName, availableQuantity, etc.
} catch (error) {
// handle error
}
}
}

No transformation needed -- the wrapper already shaped the data for the UI.