---
title: 'Shared Logic, Native UI'
source: 'https://academia.sh/en/courses/cross-platform-approaches/shared-logic-native-ui'
course: 'Cross-Platform Approaches'
language: en
updated: '2026-10-21T05:02:50+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains which layer the shared-logic and native UI approach shares and which layer it writes separately'
    - 'Measures that form validation comes out shared because both platforms run the same function'
    - 'Shows that the interface functions that build the form come out split because they are written separately, even when their results are the same'
    - 'Explains that when one of two interface functions forgets a field the sharing stays split and that the cost is stated in the prose'
---

# Shared Logic, Native UI

The shared-logic approach keeps the business rules in a shared module and writes the interface separately on each platform. The lesson measures that form validation comes out shared and the interface functions that build the form come out split, and it shows the maintenance cost of a drifted split.

The previous lesson measured an engine that draws everything and found the sharing in its widest form. This lesson goes in the opposite direction. The business rules of the app, that is, the validation, calculation, and communication rules, are written once in a module independent of the platform. The interface is written separately on each platform with its own tools. The shared module stands under both interfaces; the interface calls it, but the interface itself is not shared.

The question of the course does not change: **how is this feature's code shared between the platforms?** In this lesson the question is asked separately of the two layers. For the logic layer the answer is a single code. For the interface layer the answer is two codes.

The mechanism works as follows. The shared module takes a form input and returns the validation result. The interface of each platform builds the form with its own components, hands what the user typed to the shared module, and shows the returned result in its own form. The interface fully follows the rules of the platform: navigation, accessibility, and keyboard behavior come from the platform itself. The cost is that the interface is written twice.

The course classifies this cost, but measures it with a different axis. One question has to be answered: **where does the cost of this structure land?** (M11/K06). That course asks at which stage the cost is paid. This course classifies not the place of the cost but how many times the code is written; the maintenance cost of the split is stated below in the prose.

**AP1.** The model builds this sharing's rule; the model does not draw an interface, does not put a form on a screen, and does not take input from a user. In the model a form is an object: the `name` field is a name and the `phone` field is a phone string. The validation rule is also a chosen rule in the model: the name cannot be empty, and the phone carries at least five characters. The interface functions do not draw the form; they return as a string which fields and buttons would be built.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

We put the validation rule and the error texts in a shared file. The next three measurements import it.

```javascript
// form.mjs — shared module: validation rule and error texts
export function validateForm(form) {
  if (form.name === "") return "name-missing";
  if (form.phone.length < 5) return "phone-short";
  return "valid";
}

const MESSAGES = {
  "name-missing": "Name cannot be left empty",
  "phone-short": "Phone is too short",
  "valid": "",
};

export function errorText(code) {
  return MESSAGES[code];
}
```

This file stands under the interface of both platforms. None of its lines reads the platform, and none of its lines creates a component.

## Logic Shared, Interface Split

In the first measurement we put two features side by side. The first is validation: both platforms call the same `validateForm` function. The second is the interface function that builds the form: `iosForm` for iOS and `androidForm` for Android. The two are written separately, and each adds an error field according to the validation result.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { validateForm } from "./form.mjs";

function iosForm(form) {
  const parts = ["name", "phone", "submit"];
  if (validateForm(form) !== "valid") parts.push("error");
  return parts.join(",");
}

function androidForm(form) {
  const fields = ["name", "phone"];
  const base = fields.concat(["submit"]);
  return validateForm(form) === "valid" ? base.join(",") : base.concat("error").join(",");
}

const forms = [
  { name: "Ada", phone: "55501" },
  { name: "", phone: "55501" },
  { name: "Ada", phone: "12" },
  { name: "Can", phone: "55502" },
];

const features = [
  new Sharing("form-validation", { ios: validateForm, android: validateForm }, forms),
  new Sharing("form-ui", { ios: iosForm, android: androidForm }, forms),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
form-validation: code=1 | diff=0/4 -> shared
form-ui: code=2 | diff=0/4 -> split
shared: 1
branched: 0
split: 1
single: 0
```

The two lines read in the same way. For `form-validation` the output was `code=1` and `diff=0/4`; this is a shared feature. For `form-ui` the output was `diff=0/4`, which means the two functions produced the same fields in all four forms, but because `code=2` the sharing is **split**. The rule sees not the result but the number of codes. Two separate codes that give the same output are not a single code.

This difference is the most confused place of the course. The results being the same is good news, but it is not a guarantee. The two functions give the same result today because the same person wrote both on the same day. One day one of them changes and the other does not, and the results drift apart. The definition of split rests on this possibility: there are two codes, and each asks for maintenance separately.

The source counterparts of the same measurement are as follows. The two pieces are not measured; the model does not run an interface. Still, they show what two separate codebases look like.

```swift
// Platform code: SignupForm
import SwiftUI

struct SignupForm: View {
    @State private var name = ""
    @State private var phone = ""
    let validate: (String, String) -> String

    var body: some View {
        Form {
            TextField("Name", text: $name)
            TextField("Phone", text: $phone)
            Button("Submit") { _ = validate(name, phone) }
        }
    }
}
```

```kotlin
// Platform code: SignupForm
@Composable
fun SignupForm(validate: (String, String) -> String) {
    var name by remember { mutableStateOf("") }
    var phone by remember { mutableStateOf("") }
    Column {
        TextField(value = name, onValueChange = { name = it })
        TextField(value = phone, onValueChange = { phone = it })
        Button(onClick = { validate(name, phone) }) { Text("Submit") }
    }
}
```

What the two pieces have in common is the `validate` parameter: both take the validation from outside. This is the job of the shared module. Everything that is separate lies in the bodies of the pieces: holding state, the field component, and the button are the platform's own tools.

## Error View: Shared Text, Split Drawing

The error text is also divided into two layers. The text itself, that is, the sentence "Phone is too short", stays in the shared module as a rule: an error code goes in, a text comes out. How the text looks on the screen is the job of the platform. Each platform draws the error line in its own design language.

**AP2.** In the model a "view" is a string: the iOS interface function wraps the text in square brackets, and the Android interface function wraps it in angle brackets. The choice of bracket is an arbitrary representation of the model; it has nothing to do with the error views of real platforms.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { errorText } from "./form.mjs";

const iosBanner = (code) => `[${errorText(code)}]`;
const androidBanner = (code) => `<${errorText(code)}>`;

const codes = ["name-missing", "phone-short", "valid"];

const features = [
  new Sharing("error-text", { ios: errorText, android: errorText }, codes),
  new Sharing("error-view", { ios: iosBanner, android: androidBanner }, codes),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
error-text: code=1 | diff=0/3 -> shared
error-view: code=2 | diff=3/3 -> split
shared: 1
branched: 0
split: 1
single: 0
```

In this measurement the line between split and branched becomes visible. For `error-view` the output was `diff=3/3`: the two platforms gave different results in all three codes. Still, the sharing is not branched but **split**, because the two functions are written separately. Branched is a single function branching by platform. Split is the existence of two functions. The results being different is intentional here: each platform wants its own view, and separating the two interface functions for this reason is a chosen cost of the approach.

A design decision follows from here. Because the error text stays in the shared module, changing the text is done in a single place and goes to both platforms together. Changing the view is done in two places. The approach puts things with different rates of change in different layers: the rule that changes often and is independent of the platform goes to the shared module, and the drawing that is tied to the platform goes to the native UI.

## The Bounding Measurement

The cost of a split is that the two codes can drift apart. To measure this we compare two versions of the same interface function. The matched pair produces the same fields in every form. In the drifted pair the Android version has forgotten the newly added phone field.

**AP3.** In the model "forgetting" is a typing error: the `androidFormStale` function never puts the `phone` field in the list. In real teams this happens as a change made on one platform not being carried to the other; the model does not imitate this process, it only writes its result as a function.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { validateForm } from "./form.mjs";

function iosForm(form) {
  const parts = ["name", "phone", "submit"];
  if (validateForm(form) !== "valid") parts.push("error");
  return parts.join(",");
}

function androidForm(form) {
  const base = ["name", "phone", "submit"];
  return validateForm(form) === "valid" ? base.join(",") : base.concat("error").join(",");
}

function androidFormStale(form) {
  const base = ["name", "submit"];
  return validateForm(form) === "valid" ? base.join(",") : base.concat("error").join(",");
}

const forms = [
  { name: "Ada", phone: "55501" },
  { name: "", phone: "55501" },
  { name: "Ada", phone: "12" },
  { name: "Can", phone: "55502" },
];

const features = [
  new Sharing("form-matched", { ios: iosForm, android: androidForm }, forms),
  new Sharing("form-drifted", { ios: iosForm, android: androidFormStale }, forms),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
form-matched: code=2 | diff=0/4 -> split
form-drifted: code=2 | diff=4/4 -> split
shared: 0
branched: 0
split: 2
single: 0
```

Both features came out **split**. For `form-matched` `diff=0/4` is read and for `form-drifted` `diff=4/4`, and the sharing of the two is the same. The rule reports the `diff` count but does not use it in deciding the sharing: as long as `code=2` it is split. This is the limit of the rule, and it was built this way on purpose. A zero diff is the state of today. A diff greater than zero is the drifted state of the split, and the measurement catches it; even so, it does not change the sharing.

The cost of the drifted split is stated in the prose, because the rule cannot count it. When the Android version forgets the phone field, the validation still checks the phone, but the user cannot see the field. The defect is not in the validation layer but in the interface layer, and nobody finds it in the validation tests. Defects of this kind stay hidden until the screens of the two platforms are put side by side and compared. As the team grows, the comparison is made less often and the defect lives longer.

For this reason it is not enough to measure the split of the approach; a safeguard is needed so that the split does not drift. A shared interface specification, a test that compares the screenshots of the two platforms, or a review rule that requires both platforms for every added field are among these safeguards. The rule sees none of them; it sees only that the code is written twice.

## Summary

- This lesson's sharing: split. The logic layer is a single code in the shared module, but the interface, which is the carrying measurement of the lesson, is written separately on the two platforms.
- Form validation ran with the same function on the two platforms and gave no diff in four forms: the sharing came out shared.
- The interface functions that build the form produced the same fields in all four forms, but because they are two separate codes the sharing came out split: the result being the same does not mean a single code.
- When one of the two interface functions forgot a field, `diff` became greater than zero, but the sharing stayed split; the maintenance cost of a drifted split is a subject for the prose, not for the rule.
- The approach puts the platform-independent rule that changes often in the shared module and the platform-bound drawing in the native UI; its cost is that the interface is written twice and that the two writings may move away from each other.

## Next Step

In this lesson and the previous ones, five approaches were measured one by one. Each approach had a sharing: we saw shared, branched, and split outputs. The next lesson, `Approach Selection Criteria`, measures the same list of features with two approaches and shows how the four sharings of the course are distributed on a single screen.
