> ## Content Index
> Fetch the complete content index at: https://williamboles.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Escaping the Cohabiting Trap
- URL: https://williamboles.com/escaping-the-cohabiting-trap/
- Published: 2026-10-12T09:40:16.000Z
- Updated: 2026-10-12T13:37:34.000Z
- Description: The wrong abstraction leaves you with a type that is shared in name only - each caller switching off what the other switches on. Here, we look at how to spot this cohabiting trap, and how to escape by letting each caller take back what is theirs.
- Author: William Boles
- Tags: Abstraction, Refactoring, Separation of Concerns

Sitting at opposite ends of the sofa, watching a movie that neither of you would have chosen. You wait for it to finish.

Thinking back, it didn't start like this. You got together because you wanted the same things. But you drifted over time. First, you joined a running club, and Saturday brunches went away. Then, they started a pottery class, and Thursday date night went away as well. The time you spent with each other gradually dwindled. Nobody did anything wrong.

To your friends, you still look like a couple, but you are really just cohabiting 😬.

Our code can end up in the same place. It tends to start somewhere tidy: a single shared type that does the same job for everything that uses it. A change request arrives that only one of its callers cares about, and that type gains a flag to handle it. Then more requests. Slowly, that shared type fills up with unrelated paths, and by the time anyone notices, removing those unrelated paths is daunting because you don't know all the use cases, so you put off doing the work. It still works, after all. Then one day, a one-line change for one feature trips over a rule that was written for a different one and things start to break.

![A photo of two people at opposite ends of a sofa, uninterested in each other](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/2026/10/photo-of-uninterested-couple-on-sofa-min.jpg)

This post will explore how to tell when a shared component has stopped working for the callers that share it, and what to do about it. We'll do that twice: once with a component that can be saved, and once with one that can't.

## Sharing an Avatar

Our first shared component lives in a social media app, where a user's avatar appears at the top of their own profile and beside each message in a conversation. The view that draws it began as just a photo in a circle, then an edit badge was added to support profile editing, and an online dot to support chat.

Let's look at that view as it is today:

```
struct AvatarView: View {
    let user: User
    let size: CGFloat
    let showEditBadge: Bool
    let showOnlineStatus: Bool

    // 1
    var body: some View {
        avatar
            .frame(width: size, height: size)
            .background(.gray)
            .clipShape(Circle())
            // 2
            .overlay(alignment: .bottomTrailing) {
                if showEditBadge {
                    Image(systemName: "pencil.circle.fill")
                }
            }
            // 3
            .overlay(alignment: .topTrailing) {
                if showOnlineStatus && user.isOnline {
                    Circle()
                        .fill(.green)
                        .frame(width: 12, height: 12)
                }
            }
    }

    // 4
    @ViewBuilder
    private var avatar: some View {
        if let photoURL = user.photoURL {
            AsyncImage(url: photoURL) { phase in
                switch phase {
                case .success(let image):
                    image
                        .resizable()
                        .scaledToFill()
                case .failure:
                    initial
                case .empty:
                    ProgressView()
                @unknown default:
                    initial
                }
            }
        } else {
            initial
        }
    }

    private var initial: some View {
        Text(user.name.prefix(1))
    }
}
```

Here's what it does:

1. `body` draws the user's avatar as a circle of the given size, on a grey background, and then adds the two decorations that follow.
2. First decoration - an overlay adds a pencil-shaped edit badge when `showEditBadge` is set, to indicate the avatar can be changed.
3. Second decoration - an overlay adds a green online dot when `showOnlineStatus` is set, and only if the user is online.
4. `avatar` shows the user's photo when one is available or, failing that, the first letter of their name.

### Leading Separate Lives

`AvatarView` has two call sites:

```
struct ProfileHeader: View {
    let user: User

    var body: some View {
        // Omitted other views

        AvatarView(user: user,
                   size: 96,
                   showEditBadge: true,
                   showOnlineStatus: false)
    }
}

struct MessageRow: View {
    let sender: User

    var body: some View {
        // Omitted other views

        AvatarView(user: sender,
                   size: 40,
                   showEditBadge: false,
                   showOnlineStatus: true)
    }
}
```

Each caller hands over a user and a size, and then answers two questions.

- `ProfileHeader` wants the edit badge, as it's where you change your own photo. It turns the online dot down, as you don't need to be told that you're online.
- `MessageRow` wants the online dot, as it shows who is around to reply. It turns the edit badge down, as photos aren't changed from a conversation.

Both call sites are simple enough, but notice that they never agree: each switches on the flag the other switches off. The flags are a clue. When callers consistently opt in to different parts of a shared type, it's worth asking whether those parts are truly shared or merely cohabiting.

Let's examine that cohabiting code:

```
struct AvatarView: View {
    // Omitted non-cohabiting properties
    let showEditBadge: Bool
    let showOnlineStatus: Bool

    var body: some View {
        avatar
            // Omitted non-cohabiting code
            .overlay(alignment: .bottomTrailing) {
                if showEditBadge {
                    Image(systemName: "pencil.circle.fill")
                }
            }
            .overlay(alignment: .topTrailing) {
                if showOnlineStatus && user.isOnline {
                    Circle()
                        .fill(.green)
                        .frame(width: 12, height: 12)
                }
            }
    }

    // Omitted non-cohabiting views
}
```

Neither decoration overlay is about drawing an avatar - each exists for one caller. `AvatarView` knows things about its callers that it has no business knowing, so `ProfileHeader` and `MessageRow` are coupled through it: a change to the online dot means retesting the profile header.

The two flags also mean `AvatarView` supports four configurations when only two are ever asked for. Those unwanted configurations still cost us. Anyone reading `body` has to work out which overlay belongs to which caller, and anyone testing it has to choose between writing tests for configurations no screen uses or leaving those configurations untested.

> You might be thinking: *"Two flags on a view - so what?"* But the cost of the flags is paid when we add the next decoration to `AvatarView`. If `TypingIndicator` wants a pulsing ring around the avatar of the person typing, it can't *just* add one because adding that ring would also add it for `ProfileHeader` and `MessageRow`. Instead, `AvatarView` would gain a third flag that `ProfileHeader` and `MessageRow` would then need to set to `false`. Those original four configurations would then become eight. Each further flag doubles them again, adding testing effort and risk.
> 
> Defaulting new flags to `false` would spare the existing callers from changing, but only by hiding the issue - the new configurations would still be there.

Let's see how we can improve things.

### Giving Each Other Space

We'll start by stripping out anything that isn't genuinely shared from `AvatarView`:

```
struct AvatarView: View {
    let user: User
    let size: CGFloat

    var body: some View {
        avatar
            .frame(width: size, height: size)
            .background(.gray)
            .clipShape(Circle())
    }

    // Omitted unchanged properties
}
```

Both decoration overlays are gone, leaving only what `ProfileHeader` and `MessageRow` share.

First, `ProfileHeader` takes back its edit badge:

```
struct ProfileHeader: View {
    let user: User

    var body: some View {
        // Omitted other views

        AvatarView(user: user,
                   size: 96)
            // 1
            .overlay(alignment: .bottomTrailing) {
                Image(systemName: "pencil.circle.fill")
            }
    }
}
```

Here's what we did:

1. `ProfileHeader` adds the edit badge itself. Its avatar always has one, so there is no flag and no branching.

Next is `MessageRow`:

```
struct MessageRow: View {
    let sender: User

    var body: some View {
        // Omitted other views

        AvatarView(user: sender,
                   size: 40)
            // 1
            .overlay(alignment: .topTrailing) {
                if sender.isOnline {
                    Circle()
                        .fill(.green)
                        .frame(width: 12, height: 12)
                }
            }
    }
}
```

Here's what we did:

1. `MessageRow` adds the online dot itself. The `if` survives, but it has changed what it's asking. Before, it mixed a question about the caller with one about the user; now only `sender.isOnline` is left.

With those changes, the cohabiting code and flags have moved out of `AvatarView`, and what began as four configurations has now become just the **one**. Everything `AvatarView` does, both callers want. And when `TypingIndicator` arrives, its ring is an overlay inside `TypingIndicator` \- no third flag, and nothing for the other two to turn down.

So `AvatarView` survives. `TotalCalculator` isn't so lucky.

## Sharing a Total

Our second shared component lives in a shopping app, where a total appears beneath both the basket and the wishlist. The type that calculates it began as just a sum of prices, then a three-for-two offer and a delivery charge were added to support the basket, and an out-of-stock filter was added to support the wishlist.

Let's look at that type as it is today:

```
final class TotalCalculator {

    // 1
    func calculateTotal(for products: [Product],
                        applyThreeForTwoOffer: Bool,
                        applyOutOfStockFilter: Bool,
                        applyDeliveryCharge: Bool) -> Decimal {
        // 2
        let pricedProducts = applyOutOfStockFilter ? products.filter { $0.isInStock } : products
        let sum = pricedProducts.reduce(0) { $0 + $1.price }

        // 3
        let cheapest = products
                        .map { $0.price }
                        .min() ?? 0
        let discount = applyThreeForTwoOffer && products.count >= 3 ? cheapest : 0

        let subtotal = sum - discount

        // 4
        let deliveryCharge: Decimal = applyDeliveryCharge && subtotal < 50 ? 4 : 0

        let total = subtotal + deliveryCharge

        return total
    }
}
```

Here's what it does:

1. `calculateTotal(for:applyThreeForTwoOffer:applyOutOfStockFilter:applyDeliveryCharge:)` takes a flag for each of the three rules it contains.
2. Out-of-stock rule - the prices are added up, skipping out-of-stock products when `applyOutOfStockFilter` is set.
3. Three-for-two rule - three-for-two is applied when `applyThreeForTwoOffer` is set. To keep things simple, it is only applied once: with three or more products, the cheapest is free.
4. Delivery rule - delivery is charged when `applyDeliveryCharge` is set: £4, and free once the customer spends £50\. To keep things simple, there is no special case for an empty basket.

`TotalCalculator` has two call sites:

```
struct Basket {
    let products: [Product]
    private let calculator = TotalCalculator()

    var total: Decimal {
        let total = calculator.calculateTotal(for: products,
                                              applyThreeForTwoOffer: true,
                                              applyOutOfStockFilter: false,
                                              applyDeliveryCharge: true)

        return total
    }
}

struct Wishlist {
    let products: [Product]
    private let calculator = TotalCalculator()

    var total: Decimal {
        let total = calculator.calculateTotal(for: products,
                                              applyThreeForTwoOffer: false,
                                              applyOutOfStockFilter: true,
                                              applyDeliveryCharge: false)

        return total
    }
}
```

Each caller hands over its products, and then answers three questions.

- `Basket` wants three-for-two and delivery, as its total is what the customer pays. It turns the out-of-stock rule down, as an out-of-stock product can't be added to a basket.
- `Wishlist` wants the out-of-stock rule, as its total shows what the customer could buy today, and a sold-out product can't be bought. It turns the other two down, as nothing on a wishlist is bought or delivered until it's moved to the basket.

The lack of overlap in setting the flags is a clear indication that `TotalCalculator` contains cohabiting code.

**But** before we can look into it, a change request arrives.

### Running Out of Stock

An out-of-stock product can't be added to a basket, but one that's already in it can sell out before the customer checks out. When that happens, the product can no longer be bought, yet it's still included in the basket's total. The basket needs to skip out-of-stock products in its total, just as the wishlist does.

`TotalCalculator` already knows how to do that. So it's a quick, small change to switch `applyOutOfStockFilter` from `false` to `true`:

```
struct Basket {
    // Omitted unchanged code

    var total: Decimal {
        let total = calculator.calculateTotal(for: products,
                                              applyThreeForTwoOffer: true,
                                              applyOutOfStockFilter: true,
                                              applyDeliveryCharge: true)

        return total
    }
}
```

Initially, this looks like a shared component that's started paying off: `Wishlist` asked for the rule, and `Basket` gets it for free. So, we add a test for a basket holding two products, one of them out of stock; the test goes green, and we ship the update.

Then we get a message from Greg in finance, and he is not a happy bunny. A customer had three products in their basket: one at £60, one at £45 and one at £30\. The £45 product had gone out of stock, so the customer should have paid £90, but instead they only paid £60 😱.

We go digging and discover that the miscalculation was caused by how the out-of-stock rule interacted with the total calculation - the out-of-stock product was left out of the sum, but it still counted towards three-for-two.

The test we added only had two products, so the three-for-two rule was never triggered. When we add in a second test built with three products, we are able to reproduce the bug that Greg reported.

The whole diff was one flag in `Basket` switching from `false` to `true` \- `TotalCalculator` didn't change at all. What changed is that, for the first time, a caller asked for both the three-for-two and out-of-stock rules to be applied at the same time. The out-of-stock rule was added for `Wishlist` after three-for-two already existed. Whoever added it changed the line that sums the prices - the only line `Wishlist` relies on - and had no reason to touch an offer `Wishlist` never uses, so the question of whether a product that isn't in stock should count towards three-for-two never came up.

Because `TotalCalculator` let a caller set both `applyOutOfStockFilter` and `applyThreeForTwoOffer` to `true`, we assumed that combination was supported and already covered by unit tests 😳.

Let's look at what made the bug possible.

### Nothing in Common

Here's `TotalCalculator` stripped back to its cohabiting code:

```
final class TotalCalculator {

    func calculateTotal(for products: [Product],
                        applyThreeForTwoOffer: Bool,
                        applyOutOfStockFilter: Bool,
                        applyDeliveryCharge: Bool) -> Decimal {
        let pricedProducts = applyOutOfStockFilter ? products.filter { $0.isInStock } : products

        // Omitted non-cohabiting code

        let discount = applyThreeForTwoOffer && products.count >= 3 ? cheapest : 0

        // Omitted non-cohabiting code

        let deliveryCharge: Decimal = applyDeliveryCharge && subtotal < 50 ? 4 : 0

        // Omitted non-cohabiting code
    }
}
```

`TotalCalculator` knows that a basket has an offer and a delivery charge, and that a wishlist has neither. None of that is its business.

Those three flags mean there are eight possible configurations of `calculateTotal(for:applyThreeForTwoOffer:applyOutOfStockFilter:applyDeliveryCharge:)`:

| # | Three-for-Two Offer | Out-of-Stock Filter | Delivery Charge |
| - | ------------------- | ------------------- | --------------- |
| 1 | false               | false               | false           |
| 2 | false               | false               | true            |
| 3 | false               | true                | false           |
| 4 | false               | true                | true            |
| 5 | true                | false               | false           |
| 6 | true                | false               | true            |
| 7 | true                | true                | false           |
| 8 | true                | true                | true            |

`TotalCalculator` accepts all eight configurations, but only two have defined behaviour: configuration `6` for `Basket` and configuration `3` for `Wishlist`. The other six are undefined: no business rule says what the total should be - only the code does. A new developer reading `TotalCalculator` wouldn't be able to tell which configurations have been defined and which haven't. By exposing all eight, `TotalCalculator` implicitly promises a new developer that each of them is valid. But when we switched `Basket` from configuration `6` to configuration `8`, we moved the app onto a valid code path with undefined business behaviour - resulting in a production bug.

> Undefined configurations don't just weigh on the production code; the test suite pays for them too. A test for one of them can only assert whatever `TotalCalculator` happens to return today, which turns an accident into a requirement. Worse, tests double as documentation - a future developer reading them would take that accident as the definition of correct, making the false promises in `TotalCalculator` more convincing. And the cost compounds: each new flag doubles the configurations, and each configuration needs more than one set of products to exercise its rules. Leaving them untested is cheaper, but that leaves a blind spot in exactly the place where the production bug was hiding.

In configuration `8`, `Basket` now sets `applyOutOfStockFilter` to `true`, just as `Wishlist` does in configuration `3`, so it looks like the one thing they share. But the flag is doing two jobs. `applyOutOfStockFilter` was added to mean "only count what can be bought today". `Basket` uses it to mean "don't charge for what we can't send". Those happen to be the same filter, but they are two decisions with two owners, sharing one answer. If the business later decides the wishlist's total should include sold-out products, to show customers what they're waiting for, the basket still mustn't charge for them - and one flag can't give both answers.

The quickest fix is two small edits in `TotalCalculator`: take `cheapest` and the product count from `pricedProducts` instead of `products`. Both are needed - fixing only the count would still let a sold-out product be the cheapest, discounting something the customer isn't being charged for. That would make Greg happy, and the rule itself is right - a sold-out product shouldn't count towards three-for-two. But the basket's rule would be being decided inside `TotalCalculator`, by a flag that was added for the wishlist.

That fix would also leave the six undefined configurations in place, waiting to trip up the next unsuspecting developer who opts into one of them. Something has to change to remove them, and not just in `TotalCalculator` \- `Basket` and `Wishlist` also need to be restructured.

### Dividing the Belongings

What separates `TotalCalculator` from `AvatarView` is where the cohabiting code sits. `AvatarView`'s decorations sat on top of the avatar, so each caller could add its own from outside. `TotalCalculator`'s rules sit in the middle of the calculation - the out-of-stock rule decides what three-for-two counts, and three-for-two decides whether delivery is charged - so there is no outside for `Basket` to add them from.

That means once we start stripping functionality out of `TotalCalculator`, `Basket` can no longer get its total from it. Moving three-for-two and the delivery charge to `Basket` leaves `TotalCalculator` with only the responsibility of filtering out the out-of-stock products and adding up the rest. In fact, even the out-of-stock filtering needs to be duplicated in `Basket` to resolve the three-for-two bug we saw.

In the end, using `TotalCalculator` just isn't worth it for `Basket`, as the only thing `TotalCalculator` would still be doing for it is a single call to [reduce(\_:\_:)](https://developer.apple.com/documentation/swift/array/reduce%28%5F:%5F:%29?ref=williamboles.com), which `Basket` can easily absorb.

So `Basket` ends up owning its whole total calculation:

```
struct Basket {
    let products: [Product]

    var total: Decimal {
        // 1
        let inStockProducts = products
                                .filter { $0.isInStock }

        let sum = inStockProducts
                    .reduce(0) { $0 + $1.price }

        // 2
        let cheapest = inStockProducts
                        .map { $0.price }
                        .min() ?? 0
        let discount = inStockProducts.count >= 3 ? cheapest : 0

        let subtotal = sum - discount

        // 3
        let deliveryCharge: Decimal = subtotal < 50 ? 4 : 0

        let total = subtotal + deliveryCharge

        return total
    }
}
```

Here's what we did:

1. The out-of-stock rule has moved across from `TotalCalculator` without its `applyOutOfStockFilter` flag - a basket always filters out-of-stock products. `Basket` does its own filtering to ensure that any subsequent rules are working with the correct list of products.
2. The three-for-two rule has moved across without its `applyThreeForTwoOffer` flag - a basket always has three-for-two. It works from `inStockProducts`, so it no longer counts a product that isn't being bought. The fix now lives in the only type that has three-for-two, where the next person to change either rule will see the other.
3. The delivery charge has moved across without its `applyDeliveryCharge` flag - a basket is always delivered. The delivery rule depends on the subtotal, and `Basket` is now where the subtotal is worked out, so the decision sits with the owner of the data it needs.

With these changes, the bug is fixed and every line in `total` is there because a basket needs it - a win for Greg, and a win for whoever reads this code next.

> IMHO, using code structure to prevent bugs is top-tier [defensive programming](https://williamboles.com/defensive-by-design/) and always my go-to before adding in guards like [assert(\_:\_:file:line:)](https://developer.apple.com/documentation/swift/assert%28%5F:%5F:file:line:%29?ref=williamboles.com). It's one of the things I most admire when I find it in someone else's code. I was first really introduced to this concept during a WWDC session, where the engineer was describing the reasoning behind deprecating [viewDidUnload](https://developer.apple.com/documentation/uikit/uiviewcontroller/viewdidunload?ref=williamboles.com) in [UIViewController](https://developer.apple.com/documentation/uikit/uiviewcontroller?ref=williamboles.com) back in iOS 6\. With a change to the structure of `UIViewController`, the `UIKit` engineers solved a source of crashes for many apps that they never directly touched.

Now that `Basket` works out its own total, `Wishlist` is left as the only caller of `TotalCalculator`. Code with one caller isn't shared, so `Wishlist` takes its total back as well:

```
struct Wishlist {
    let products: [Product]

    // 1
    var total: Decimal {
        let inStockProducts = products
                                .filter { $0.isInStock }

        let total = inStockProducts
                        .reduce(0) { $0 + $1.price }

        return total
    }
}
```

Here's what we did:

1. `total` filters out sold-out products, as they can't be bought today, and then works out the one thing a wishlist wants: the sum of what's left. No offer or delivery charge can reach it, so there is nothing for it to turn down.

Of the eight configurations we listed for `TotalCalculator`, only two remain - the wishlist's and the basket's new one - and a business rule now stands behind both. The other six can no longer be asked for at all. With no flags to combine, the six undefined combinations disappear. The tests can focus on the rules each type actually owns, rather than trying to establish behaviour for combinations the business never defined.

`TotalCalculator` has no callers, and we can delete it.

> You might be thinking: "Isn't this a violation of [DRY](https://en.wikipedia.org/wiki/Don%27t%5Frepeat%5Fyourself?ref=williamboles.com)?" DRY asks that each piece of knowledge has one home, and the two filters are two pieces of knowledge that happen to read the same. If anything, `applyOutOfStockFilter` was the mistake DRY warns about in reverse: one line standing in for two pieces of knowledge.
> 
> That said, it hasn't come for free. Prices are now added up in two types; out-of-stock products are skipped in two types; and there is no longer one place to read if you want to know how totals work in the app. I think that is a fair price to pay - as [Sandi Metz](https://sandimetz.com/about/?ref=williamboles.com) puts it, [duplication is far cheaper than the wrong abstraction](https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction?ref=williamboles.com).

### Splitting Up

With those changes, the cohabiting code has split up, and the eight configurations went with it. `Basket` has one and `Wishlist` has one, and **somebody wants each of them**. Each `total` now reads from top to bottom, with no path to skip. When three-for-two comes to an end, the discount is deleted from `total` in `Basket`, and `Wishlist` will not know it happened. With `TotalCalculator`, the same change would have meant removing a flag, editing `Wishlist` so that it stopped turning down something that no longer existed, and then retesting both the basket and the wishlist.

Well done on making it this far 🥳.

## Going Your Own Way

The movie ends, and one of you finally says it: this isn't working. Some couples come out of that conversation sharing less, but meaning it. Others find that the sofa was all they had left in common.

From the outside, `AvatarView` and `TotalCalculator` both looked shared, but once we dug deeper, much of what they held was only cohabiting. So, when a change lands on a shared type, ask who owns the decision. If the answer is one caller, put it there - around the shared type if the decision can sit on the outside, and in place of it if the decision has to sit in the middle. Shared code should contain shared knowledge.

To see the start and end states of both examples, visit the [repository](https://github.com/wibosco/CohabitingTrap-Example?ref=williamboles.com).