androidinterview.com

Android System Design Interview Questions

Design a server driven home screen like Swiggy or Blinkit.

Tier: EssentialDifficulty: Hard

The whole point of a server driven home screen is that the server decides what the screen contains and in what order, so a merchandising change ships on a Tuesday afternoon without an app release. Say that first, because every hard part of this design follows from the cost that comes with it. The client stops being a screen and becomes a renderer for a contract it does not control.

What to clarify first

  • How much control does the server really have. Ordering and which widgets appear is one design. Full styling, colours, corner radii and spacing sent down the wire is a much bigger one, and usually the wrong one.
  • Does the home screen have to work offline, or at least on a two second connection. That decides whether caching is a nice to have or the centre of the design.
  • What should happen to a widget type the installed app has never heard of. Ask this out loud. It is the question the round is actually about.
  • Is the page personalised per user, because personalisation and caching pull in opposite directions and you want to know that before you pick a caching strategy.
  • How fresh does the content have to be. A grocery ETA and a restaurant open state go stale in minutes, a category grid does not.

The contract

A page is an ordered list of sections. Each section carries a type, a stable id, and a typed payload, and the client renders them top to bottom in the order given.

  • The type is the hinge. It is a string on the wire and it is mapped to a renderer exactly once, at parse time, so the rest of the app never touches it.
  • The id is stable across refreshes for the same logical section, because impressions, taps and scroll position all get reported against it.
  • The payload is typed per section type, not a free form bag. A rail knows it has a title and a list of products. Typed after the parse boundary, that is. On the wire it is still a bag of strings, and turning that into a type is what the parser is for.
  • The page carries a version of its own, a layout id or an experiment id, so an impression can be attributed to the arrangement that produced it.

Keep the number of section types small. A widget type earns its place when somebody in merchandising can actually configure it, and a contract that grows a type per screen has quietly become a worse version of shipping layouts.

The unknown widget problem

An app installed six months ago will one day be handed a section type that did not exist when it shipped. It must not crash, and it must not leave a hole in the middle of the screen. This is the part most candidates skip and the part interviewers wait for.

  • The server filters by app version. The client sends its version, the server never sends a section that build cannot render. This handles almost everything and must never be the only defence.
  • Every section carries a minimum supported app version anyway, because a CDN, a proxy or a stale cache will eventually hand an old build a newer page.
  • The server offers a fallback section alongside the new one, normally a plain banner, because banners shipped in version one and always will. The old app renders the fallback and the new app renders the real thing.
  • Anything still unrecognised is dropped silently, and parsing runs per section so one bad payload costs one rail rather than the page.

What I would actually do is all four. The version filter is the load bearing one, the minimum version and the fallback are what stop a caching layer turning a routine merchandising change into a support incident, and the drop is the floor. A missing rail is survivable, a crash on the home screen is not.

Rendering

The client keeps a registry that maps a type to a renderer. The important discipline is that the mapping happens once, at the parse boundary, and everything after that is typed.

In Kotlin the sections are a sealed interface and the dispatch is a when with no else. Add a section type that nobody wired up and the code stops compiling, which is a much better place to find out than a tester's phone. A big when over raw strings gives none of that, because a string has no cases the compiler can count. Java has sealed interfaces too, but the dispatch ends up as an instanceof chain that happily compiles while rendering nothing.

Compose makes this considerably better than the View system did. A section is a function from data to UI, so the registry is a map of type to composable and there are no view holders, no view types and no adapter to keep in step with the contract. LazyColumn gives lazy rendering of everything below the fold for free, which used to be the fiddliest part of building this on RecyclerView.

The code

Two things earn their place, the section contract and the registry that turns a wire type into it, including what happens when the type is unrecognised. The parse step tries the section, then the fallback the server offered, then drops it, and that three step is the whole answer to the unknown widget question in about four lines.

The renderer holds the parsed page and builds a section only when the list asks for that position, which is what makes the lazy rendering below real rather than a claim. The rest is left out on purpose. There is no Compose here, no Retrofit, no image loading, and the UI layer is one small interface with a method per section, which is also why the whole thing runs in a plain unit test with no device attached.

Java

com.androidinterview.sdui.model.Section.java

package com.androidinterview.sdui.model;

import java.util.List;

// The contract, and most of the design in one file. A page is an ordered list
// of sections, each one a type, an id and a typed payload. The type is the
// hinge everything turns on, and the id is what impressions and taps are
// reported against.
//
// The set of types is deliberately small. A widget earns a place here only
// when somebody in merchandising can actually configure it, otherwise it is a
// screen pretending to be a contract.
public sealed interface Section {

    String id();

    record Banner(String id, String image, String deeplink) implements Section {
    }

    record ProductRail(String id, String title, List<Product> products) implements Section {
    }

    record CategoryGrid(String id, List<Category> categories, int columns) implements Section {
    }

    record Product(String id, String name, String price, String image) {
    }

    record Category(String id, String label, String image) {
    }

    // Every image this section will ask for, so the page can prefetch what is
    // above the fold before the user scrolls. Leave it to the image loader and
    // the home screen fills in half a second late, which is the one screen
    // where that is most obvious.
    default List<String> imageUrls() {
        if (this instanceof Banner banner) return List.of(banner.image());
        if (this instanceof ProductRail rail) return rail.products().stream().map(Product::image).toList();
        if (this instanceof CategoryGrid grid) return grid.categories().stream().map(Category::image).toList();
        return List.of();
    }
}

com.androidinterview.sdui.parse.RawSection.java

package com.androidinterview.sdui.parse;

import java.util.List;
import java.util.Map;

// What actually arrives over the wire, before the client knows anything about
// it. Type is a string here and nowhere else in the app.
//
// minAppVersion is the section telling us which builds can render it properly.
// fallback is what the server offers a build that cannot, normally a plain
// banner, because banners shipped in version one and always will.
public record RawSection(
        String type,
        String id,
        int minAppVersion,
        Map<String, String> fields,
        List<Map<String, String>> items,
        RawSection fallback) {
}

com.androidinterview.sdui.parse.SectionParser.java

package com.androidinterview.sdui.parse;

import com.androidinterview.sdui.model.Section;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

// The registry, and the only place in the app where a wire type is a string.
// Past this line the page is a list of sealed types and the renderers can be
// checked by the compiler instead of by a tester.
//
// The unknown type is the question the interviewer is really asking. An app
// shipped six months ago will be handed a type nobody had invented yet, and it
// must not crash and must not leave a hole in the middle of the screen. Three
// defences, and all three are worth having.
//   1. The server filters sections by app version, which handles almost all of
//      it, and is the one that must never be the only defence.
//   2. minAppVersion travels with the section anyway, because a CDN, a proxy
//      or a stale cache will eventually hand an old build a newer page.
//   3. A fallback the server picks, and a silent drop if even that is
//      unrecognised. A missing rail is survivable, a crash is not.
public final class SectionParser {

    private static final Map<String, Function<RawSection, Section>> BUILDERS = Map.of(
            "banner", SectionParser::banner,
            "product_rail", SectionParser::productRail,
            "category_grid", SectionParser::categoryGrid);

    private final int appVersion;

    public SectionParser(int appVersion) {
        this.appVersion = appVersion;
    }

    // Section by section, so one malformed payload costs one rail rather than
    // the page. A home screen that fails to parse as a whole is an outage on
    // every installed app at once, which is why build catches rather than
    // trusting every field the server sent to be the shape it promised.
    public List<Section> parse(List<RawSection> raw) {
        List<Section> page = new ArrayList<>();
        for (RawSection candidate : raw) {
            Section section = build(candidate);
            if (section == null) section = build(candidate.fallback());
            if (section != null) page.add(section);
        }
        return page;
    }

    private Section build(RawSection raw) {
        if (raw == null || raw.minAppVersion() > appVersion) return null;
        Function<RawSection, Section> builder = BUILDERS.get(raw.type());
        if (builder == null) return null;
        try {
            return builder.apply(raw);
        } catch (RuntimeException malformed) {
            return null;
        }
    }

    private static Section banner(RawSection raw) {
        return new Section.Banner(raw.id(), raw.fields().get("image"), raw.fields().get("deeplink"));
    }

    private static Section productRail(RawSection raw) {
        List<Section.Product> products = new ArrayList<>();
        for (Map<String, String> item : raw.items()) {
            products.add(new Section.Product(
                    item.get("id"), item.get("name"), item.get("price"), item.get("image")));
        }
        return new Section.ProductRail(raw.id(), raw.fields().get("title"), products);
    }

    private static Section categoryGrid(RawSection raw) {
        List<Section.Category> categories = new ArrayList<>();
        for (Map<String, String> item : raw.items()) {
            categories.add(new Section.Category(item.get("id"), item.get("label"), item.get("image")));
        }
        return new Section.CategoryGrid(raw.id(), categories, columns(raw));
    }

    // A field the server got wrong falls back rather than throwing, and the
    // try/catch above is the floor under everything that has no sensible
    // fallback. Between them one bad payload costs one rail, never the page.
    private static int columns(RawSection raw) {
        try {
            return Integer.parseInt(raw.fields().getOrDefault("columns", "4"));
        } catch (NumberFormatException notANumber) {
            return 4;
        }
    }
}

com.androidinterview.sdui.render.HomeRenderer.java

package com.androidinterview.sdui.render;

import com.androidinterview.sdui.model.Section;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

// Dispatch and impressions, which on a server driven screen are one subject.
// The server chose what goes on the page and in what order, so it needs to
// know what was actually seen. That makes impression reporting part of the
// contract rather than something analytics bolts on afterwards.
public final class HomeRenderer {

    // Fired once per section per page, carrying the layout version the server
    // sent, so two orderings can be compared honestly.
    public interface Impressions {
        void seen(String pageVersion, String sectionId, int position);
    }

    private final SectionView view;
    private final Impressions impressions;
    private final Set<String> reported = new HashSet<>();

    private String pageVersion = "";
    private List<Section> page = List.of();

    public HomeRenderer(SectionView view, Impressions impressions) {
        this.view = view;
        this.impressions = impressions;
    }

    // Rendering takes the page and builds nothing. A new page clears the
    // reported set, and skipping that is how a pull to refresh that brings the
    // same rail back quietly loses its second impression.
    public void render(String pageVersion, List<Section> page) {
        this.pageVersion = pageVersion;
        this.page = page;
        reported.clear();
    }

    public int sectionCount() {
        return page.size();
    }

    // The list asks for a section when it is about to come on screen, so a page
    // of thirty sections builds the three the user can actually see. LazyColumn
    // calls this, and a RecyclerView adapter would call it from onBindViewHolder.
    public void bind(int position) {
        if (position < 0 || position >= page.size()) return;
        show(page.get(position));
    }

    // Nothing here forces the chain to be complete, which is the Java problem.
    // Add a fourth section type and this still compiles and silently renders
    // nothing. The Kotlin version is a when over the sealed type and refuses.
    private void show(Section section) {
        if (section instanceof Section.Banner banner) view.showBanner(banner);
        else if (section instanceof Section.ProductRail rail) view.showRail(rail);
        else if (section instanceof Section.CategoryGrid grid) view.showGrid(grid);
    }

    // Called by the list when a section has been half on screen long enough to
    // count. Half, and half a second, are product numbers, so agree them with
    // whoever reads the dashboard rather than inventing them here.
    // The bounds check is not decoration. A visibility callback can arrive one
    // frame after a refresh shrank the page, on the one screen that must never
    // crash.
    public void onVisible(int position) {
        if (position < 0 || position >= page.size()) return;
        Section section = page.get(position);
        if (reported.add(section.id())) impressions.seen(pageVersion, section.id(), position);
    }
}

com.androidinterview.sdui.render.SectionView.java

package com.androidinterview.sdui.render;

import com.androidinterview.sdui.model.Section;

// The UI layer, one interface wide. A composable sits behind each of these in
// a real app, and the renderer never finds out, which is also why the whole
// design runs in a plain unit test with no device attached.
public interface SectionView {

    void showBanner(Section.Banner banner);

    void showRail(Section.ProductRail rail);

    void showGrid(Section.CategoryGrid grid);
}

Kotlin

com.androidinterview.sdui.model.Section.kt

package com.androidinterview.sdui.model

// The contract, and most of the design in one file. A page is an ordered list
// of sections, each one a type, an id and a typed payload. The type is the
// hinge everything turns on, and the id is what impressions and taps are
// reported against.
//
// The set of types is deliberately small. A widget earns a place here only
// when somebody in merchandising can actually configure it, otherwise it is a
// screen pretending to be a contract.
sealed interface Section {
    val id: String

    data class Banner(override val id: String, val image: String, val deeplink: String) : Section

    data class ProductRail(override val id: String, val title: String, val products: List<Product>) : Section

    data class CategoryGrid(override val id: String, val categories: List<Category>, val columns: Int) : Section

    data class Product(val id: String, val name: String, val price: String, val image: String)

    data class Category(val id: String, val label: String, val image: String)
}

// Every image this section will ask for, so the page can prefetch what is
// above the fold before the user scrolls. Left to the image loader, the home
// screen fills in half a second late, on the one screen where that shows.
//
// The when has no else and needs none. Add a section type and this stops
// compiling until somebody decides what its images are.
val Section.imageUrls: List<String>
    get() = when (this) {
        is Section.Banner -> listOf(image)
        is Section.ProductRail -> products.map { it.image }
        is Section.CategoryGrid -> categories.map { it.image }
    }

com.androidinterview.sdui.parse.SectionParser.kt

package com.androidinterview.sdui.parse

import com.androidinterview.sdui.model.Section

// What actually arrives over the wire, before the client knows anything about
// it. Type is a string here and nowhere else in the app.
//
// minAppVersion is the section telling us which builds can render it properly.
// fallback is what the server offers a build that cannot, normally a plain
// banner, because banners shipped in version one and always will.
data class RawSection(
    val type: String,
    val id: String,
    val minAppVersion: Int = 0,
    val fields: Map<String, String> = emptyMap(),
    val items: List<Map<String, String>> = emptyList(),
    val fallback: RawSection? = null,
)

// The registry, and the only place in the app where a wire type is a string.
// Past this line the page is a list of sealed types and the renderers can be
// checked by the compiler instead of by a tester.
//
// The unknown type is the question the interviewer is really asking. An app
// shipped six months ago will be handed a type nobody had invented yet, and it
// must not crash and must not leave a hole in the middle of the screen. Three
// defences, and all three are worth having.
//   1. The server filters sections by app version, which handles almost all of
//      it, and is the one that must never be the only defence.
//   2. minAppVersion travels with the section anyway, because a CDN, a proxy
//      or a stale cache will eventually hand an old build a newer page.
//   3. A fallback the server picks, and a silent drop if even that is
//      unrecognised. A missing rail is survivable, a crash is not.
class SectionParser(private val appVersion: Int) {

    private val builders = mapOf<String, (RawSection) -> Section>(
        "banner" to { Section.Banner(it.id, it.field("image"), it.field("deeplink")) },
        "product_rail" to { raw ->
            Section.ProductRail(
                raw.id,
                raw.field("title"),
                raw.items.map { Section.Product(it.at("id"), it.at("name"), it.at("price"), it.at("image")) },
            )
        },
        "category_grid" to { raw ->
            Section.CategoryGrid(
                raw.id,
                raw.items.map { Section.Category(it.at("id"), it.at("label"), it.at("image")) },
                raw.field("columns").toIntOrNull() ?: 4,
            )
        },
    )

    // Section by section, so one malformed payload costs one rail rather than
    // the page. A home screen that fails to parse as a whole is an outage on
    // every installed app at once, which is why build catches rather than
    // trusting every field the server sent to be the shape it promised.
    fun parse(raw: List<RawSection>): List<Section> = raw.mapNotNull { build(it) ?: build(it.fallback) }

    private fun build(raw: RawSection?): Section? {
        if (raw == null || raw.minAppVersion > appVersion) return null
        val builder = builders[raw.type] ?: return null
        return runCatching { builder(raw) }.getOrNull()
    }
}

private fun RawSection.field(key: String) = fields[key].orEmpty()

private fun Map<String, String>.at(key: String) = this[key].orEmpty()

com.androidinterview.sdui.render.HomeRenderer.kt

package com.androidinterview.sdui.render

import com.androidinterview.sdui.model.Section

// The UI layer, one interface wide. A composable sits behind each of these in
// a real app, and the renderer never finds out, which is also why the whole
// design runs in a plain unit test with no device attached.
interface SectionView {
    fun showBanner(banner: Section.Banner)
    fun showRail(rail: Section.ProductRail)
    fun showGrid(grid: Section.CategoryGrid)
}

// Fired once per section per page, carrying the layout version the server
// sent, so two orderings can be compared honestly.
fun interface Impressions {
    fun seen(pageVersion: String, sectionId: String, position: Int)
}

// Dispatch and impressions, which on a server driven screen are one subject.
// The server chose what goes on the page and in what order, so it needs to
// know what was actually seen. That makes impression reporting part of the
// contract rather than something analytics bolts on afterwards.
class HomeRenderer(private val view: SectionView, private val impressions: Impressions) {

    private val reported = mutableSetOf<String>()
    private var pageVersion = ""
    private var page = emptyList<Section>()

    // Rendering takes the page and builds nothing. A new page clears the
    // reported set, and skipping that is how a pull to refresh that brings the
    // same rail back quietly loses its second impression.
    fun render(pageVersion: String, page: List<Section>) {
        this.pageVersion = pageVersion
        this.page = page
        reported.clear()
    }

    val sectionCount: Int get() = page.size

    // The list asks for a section when it is about to come on screen, so a page
    // of thirty sections builds the three the user can actually see. This is
    // the call a LazyColumn item makes.
    fun bind(position: Int) {
        page.getOrNull(position)?.let(::show)
    }

    // No else branch, on purpose. Add a section type that nobody wired up and
    // this refuses to compile, which is the whole reason the type is sealed.
    // The Java version is an instanceof chain that happily renders nothing.
    private fun show(section: Section) = when (section) {
        is Section.Banner -> view.showBanner(section)
        is Section.ProductRail -> view.showRail(section)
        is Section.CategoryGrid -> view.showGrid(section)
    }

    // Called by the list when a section has been half on screen long enough to
    // count. Half, and half a second, are product numbers, so agree them with
    // whoever reads the dashboard rather than inventing them here.
    // getOrNull is not decoration. A visibility callback can arrive one frame
    // after a refresh shrank the page, on the one screen that must never crash.
    fun onVisible(position: Int) {
        val section = page.getOrNull(position) ?: return
        if (reported.add(section.id)) impressions.seen(pageVersion, section.id, position)
    }
}

The Kotlin version is the one to write in an interview. The sealed hierarchy plus an exhaustive when is a compile time guarantee that every section has a renderer, and it comes to noticeably less code than the Java.

Caching and offline

Cache the last good page and render it the moment the screen opens, then refresh in the background and swap in the new page when it lands. A home screen that shows a spinner on every launch feels broken even when it is fast.

The tension is that a cached page and a personalised, time sensitive page are not the same object. A stale delivery estimate or a restaurant that closed an hour ago is worse than a spinner, because it is wrong rather than slow.

  • Cache the page, not the truth in it. Layout and ordering cache well, prices, stock and ETAs do not.
  • Let the section declare its own freshness, so a category grid can live for a day while a live order tracker never renders from cache at all.
  • Use an ETag or a last modified header so an unchanged page costs a 304 rather than a full payload.
  • Cache per user, and clear it on logout, or the next person to open the app sees somebody else's recommendations.

Performance

This is the first screen a user sees, so the budget is measured from the tap on the launcher icon, not from when the response arrives.

  • Parse cost is real. A large page of nested sections parsed on the main thread is a visible stutter, so parse off the main thread and keep the model flat.
  • Prefetch images per section, driven by the section itself declaring its image URLs, and only for what is above the fold. The section knows what it needs, the image loader does not.
  • Render lazily below the fold, so a page with thirty sections builds three. LazyColumn gives this for free, and the renderer in the tree is shaped the same way, it holds the page and builds a section when the list asks for that position.
  • Cap what the server can send. Page the sections if there are many, because an unbounded list is an outage waiting for a merchandiser to paste in one more rail.

Analytics and impressions

Server driven means the server is making the decisions, so it needs to know what those decisions produced. Impression tracking is part of the contract rather than something analytics adds later.

  • Report per section, with the section id, its position, and the page version, so two orderings can be compared honestly.
  • Define what an impression is. Half the section on screen for half a second is a common rule. It is a product number, so agree it with whoever reads the dashboard.
  • Fire once per section per page load, and clear that set on a refresh, or a pull to refresh quietly loses its second impression.
  • Batch and persist the events, because dropping impressions on a bad connection biases every ranking decision that comes after.

Versioning and rollback

A bad payload can break every installed app at once. That risk simply does not exist on a client driven screen, and naming it is worth a lot in this round.

  • Version the schema, and keep the client tolerant of unknown fields, so adding a field is never a breaking change.
  • Roll out staged, one percent then ten then everyone, with the page assembled per app version so a rollback is a server config change rather than a release.
  • Keep a kill switch per section type, so a broken rail can be turned off for everyone in seconds without touching the rest of the page.
  • Keep a known good page the server can fall back to, and let the client fall back to its cached page when the response fails to parse.
  • Contract test the payload in the backend build, because the client cannot be the only thing standing between a typo and the home screen.

What breaks

  • An old app meets a new widget. The case the interviewer is waiting for. Say the version filter, the fallback and the drop, and say which one you would rely on.
  • A bad deploy. One malformed section takes out the home screen for every user simultaneously. Per section parsing, a kill switch and staged rollout are the answer.
  • Cached content that went wrong rather than stale. A closed restaurant or an expired offer rendered from cache is a support ticket, so let sections declare their own freshness.
  • Layout thrash on refresh. The cached page renders, the fresh page reorders everything under the user's thumb. Keep section ids stable and animate rather than rebuild.
  • The contract growing without limit. Thirty section types, half of them used once, and now every release is a compatibility exercise. Deleting a type is much harder than adding one, so add slowly.

Watch