Designing a Dynamic Retail Home Page on iOS


Greetings, traveler!

I recently joined an iOS System Design podcast with Walid SASSI to discuss how to design a retail application with a dynamic, campaign-driven home page.

The conversation followed the usual system design interview format: define the scope, agree on requirements, design the API and data flow, and then examine the parts where most architectural problems appear.

I also created a runnable companion project that translates the proposed design into Swift and SwiftUI code. The repository is available on GitHub.

Defining the scope

The home page is composed of several commercial sections:

  • a hero campaign
  • campaign carousels
  • category grids
  • product carousels
  • recommendations
  • editorial or promotional content

Users can open campaigns and products, search for content, add products to the cart, and refresh the page.

Features such as checkout, payments, authentication, recommendation ranking, and complete offline synchronization remain outside the scope. Keeping these boundaries explicit prevents the design from turning into a description of an entire commerce platform.

Server-driven composition

The backend controls which sections appear, their order, content, tracking metadata, and compatibility requirements.

The iOS application still owns the native views, interactions, navigation, animations, and accessibility behavior.

This is server-driven composition rather than full server-driven UI. The server can select from a known set of native section types, but it cannot describe arbitrary view hierarchies.

enum HomeSection {
    case hero(HeroSection)
    case campaigns(CampaignSection)
    case categories(CategorySection)
    case products(ProductSection)
    case recommendations(ProductSection)
    case editorial(EditorialSection)
    case promotion(PromotionSection)
}

A closed model keeps rendering predictable and type safe. Unsupported sections can be skipped or replaced with a constrained fallback instead of breaking the whole page.

The home composition API

The main endpoint returns a page composed of sections rather than a flat list of products.

GET /home?locale=&appVersion=&userSegment=

A simplified response model looks like this:

struct HomePageDTO: Decodable {
    let sections: [HomeSectionDTO]
    let nextCursor: String?
    let layoutVersion: Int
    let ttl: TimeInterval
}

Each section contains its type, content, tracking information, and optional compatibility rules.

The response can also include a minimum supported app version or required capability. This allows the backend to avoid sending sections that an older client cannot render.

Defensive DTO mapping

Network DTOs should not reach SwiftUI views directly.

The data layer validates the response and maps supported DTOs into domain models. Invalid items can be removed individually. A malformed section can be isolated without discarding valid content from the same response.

API response
    -> DTO validation
    -> domain mapping
    -> supported HomeSection values
    -> native section views

This boundary protects the presentation layer from missing fields, unknown section types, and incompatible backend changes.

Contract problems should also be reported through analytics or observability. Silently ignoring every malformed response makes backend issues difficult to detect.

Loading and caching

The home repository owns the loading policy.

On the initial request, it can emit cached content immediately and then replace it with fresh data from the API. The companion project represents these updates with an AsyncThrowingStream.

func updates() -> AsyncThrowingStream<HomeUpdate, Error>

The flow is:

HomeView
    -> HomeViewModel
    -> HomeRepository
    -> cached page
    -> API request
    -> DTO mapping
    -> updated page

The cache key should include context that can change the response, such as locale, user segment, or experiment assignment.

Concurrent refresh requests share the same in-flight operation. This avoids duplicate API calls when several events request a refresh at nearly the same time.

Keeping failures local

Different failures require different UI responses.

An initial loading failure may show a full-screen error. A refresh failure should preserve the existing page. An invalid section should affect only that section. A failed image should show a placeholder.

The same principle applies to non-visual services. Analytics failures should never block rendering or navigation.

Initial request failure -> full-screen retry
Refresh failure         -> keep existing content
Invalid section         -> drop or replace the section
Invalid item            -> drop the item
Image failure           -> placeholder
Analytics failure       -> ignore in the UI

A broken promotional section should not make the entire home page unusable.

Optimistic cart updates

Adding a product to the cart benefits from an optimistic update because the user expects immediate feedback.

User taps Add
    -> update local cart state
    -> send mutation
    -> receive canonical cart state
    -> reconcile or roll back

The server remains the source of truth for price, availability, and the final item count.

The client must also protect itself from stale responses when several cart mutations run concurrently. One option is to attach a monotonic revision to each canonical cart response and ignore revisions older than the current local state.

Retries should be idempotent so that a lost response does not create duplicate cart items.

Tracking real impressions

An item should not produce an impression merely because it was decoded or inserted into the view hierarchy.

The event should be sent only after the item becomes meaningfully visible. The companion project uses a 50 percent visibility threshold.

This keeps analytics separate from section construction and avoids counting prefetched or off-screen content as viewed.

Tracking metadata should include identifiers such as:

requestId
sectionId
itemId
campaignId
position
experimentId

These values allow backend teams to connect an interaction to the exact page composition that produced it.

Image loading

A retail home page can contain dozens of large remote images, so image loading deserves its own application-level component.

The image pipeline in the sample handles:

  • memory caching
  • in-flight request deduplication
  • target-size downsampling
  • cancellation
  • cache keys based on URL and requested size
View
    -> ImagePipeline
    -> memory cache
    -> existing in-flight request
    -> network or CDN
    -> downsample
    -> render

Downsampling matters because downloading a large image and decoding it at its original resolution wastes memory when the view only needs a small thumbnail.

In a production application, a mature library such as Nuke, Kingfisher, or SDWebImage may be a better choice than maintaining a custom pipeline. The surrounding architecture does not depend on that implementation choice.

SwiftUI or UIKit

UIKit is a reasonable option for a large production feed where teams need detailed control over layout, reuse, and prefetching. However, always measure first. SwiftUI is undoubtedly a more developer-friendly option for modern teams.

The companion application uses SwiftUI because it keeps the sample smaller and easier to inspect.

The main architectural decisions do not depend on the UI framework. The same domain models, repositories, API clients, cache policy, analytics boundary, and image pipeline could support a UICollectionView with compositional layout and a diffable data source.

Composition root

The application creates shared dependencies in one composition root.

AppComposition
    -> HomeRepository
    -> HomeAPIClient
    -> HomeCache
    -> CartClient
    -> AnalyticsTracker
    -> ImagePipeline
    -> HomeViewModel

The sample avoids a service locator and does not create protocols for every concrete dependency.

HomeRepository is concrete because it already owns meaningful policy around caching, freshness, mapping, and refresh behavior. Additional abstractions should appear when multiple implementations or real module boundaries require them.

The companion project

The repository includes local demo clients, so it runs without a backend or third-party dependencies.

It demonstrates:

  • typed native sections
  • defensive DTO mapping
  • cache-first loading
  • single-flight refresh
  • optimistic cart reconciliation
  • visibility-based analytics
  • cancellable search suggestions
  • image downsampling and request deduplication
  • focused tests for the main architectural decisions

It is an executable example of the podcast discussion, not a production commerce framework. The project intentionally leaves out checkout, authentication, full search, recommendation ranking, and offline synchronization.

You can watch the podcast and explore the implementation in the RetailApp repository.