RORK LABJP
MAX — Rork Max builds native Swift apps instead of React Native for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageNATIVE — Rork Max unlocks native features like AR/LiDAR scanning, Metal 3D games, widgets, and Dynamic IslandAPPLE — It also reaches advanced Apple capabilities such as HealthKit, HomeKit, NFC, App Clips, and on-device Core MLRN — Standard Rork builds iOS and Android from a single prompt using React Native (Expo)PRICE — Rork is free to start, with paid plans beginning at $25 per monthGROWTH — Rork now sees 743,000+ monthly visits, 85% growth, and recently raised $15MMAX — Rork Max builds native Swift apps instead of React Native for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageNATIVE — Rork Max unlocks native features like AR/LiDAR scanning, Metal 3D games, widgets, and Dynamic IslandAPPLE — It also reaches advanced Apple capabilities such as HealthKit, HomeKit, NFC, App Clips, and on-device Core MLRN — Standard Rork builds iOS and Android from a single prompt using React Native (Expo)PRICE — Rork is free to start, with paid plans beginning at $25 per monthGROWTH — Rork now sees 743,000+ monthly visits, 85% growth, and recently raised $15M
Articles/Dev Tools
Dev Tools/2026-07-09Advanced

Holding Layer Boundaries in a Rork-Generated Expo App with ESLint and dependency-cruiser

Long-lived Rork-generated Expo apps quietly accumulate screens that import the API client directly. Here is how I froze 214 existing violations as a baseline, eliminated 17 circular dependencies, and made CI reject anything new for 38 extra seconds.

Rork495Expo137Architecture14ESLintCI3

Premium Article

I opened a screen component I had not touched in six months and found import { supabase } from "../../lib/supabaseClient" sitting at the top.

That screen was supposed to receive its data through a hook. But across rounds of generation and hand-editing, the shortest path had quietly won. There is nobody to blame. I am the one who once said "just this once, just here" — and that single line got read back by the next generation pass, then copied into the next screen.

When you maintain several apps as an indie developer, this kind of decay happens without a sound. No reviewer is watching. So the reviewer has to become a machine.

Let me say up front what this article does not cover. It is not an argument about the ideal layer architecture. It is about taking an app already live on the App Store, keeping its current structure, and making sure it stops getting worse.

Declare the boundary before you defend it

The first thing that tripped me up was that nobody had written down where the boundaries were. They lived in my head. They did not live in any file.

So I kept the existing directory layout untouched and simply wrote the allowed direction of dependency into a single table. No new architecture invented. Just names given to what already existed. That choice alone changed the size of the job.

LayerPathMay import
app (screens)app/**features, ui, shared
featuressrc/features/**own feature, data, ui, shared
data (fetch and persistence)src/data/**shared only
ui (presentational)src/ui/**shared only
shared (types, constants, pure functions)src/shared/**nothing (leaf)

The interesting constraint is the ban on feature-to-feature imports. Ask Rork to "show an unread badge on the favorites screen" and you get code where features/favorites reaches straight into features/notifications. It works. It works right up until the day you want to rebuild one of them and discover the other is a hostage.

If the crossing is genuinely needed, the type belongs in shared and the query belongs in data. The whole exercise is about moving decisions out of places where a decision has to be made every time.

Make the wrong import unwriteable

ESLint's no-restricted-imports earned its place first, because the red squiggle appears in the editor. I notice it the moment the code is generated — a full day before CI would have told me.

With Flat Config, each zone gets its own block.

// eslint.config.js
import tseslint from "typescript-eslint";
 
/** Import patterns forbidden per layer */
const layerRules = {
  "src/data/**": ["@/features/*", "@/ui/*", "@/app/*"],
  "src/ui/**": ["@/features/*", "@/data/*", "@/app/*"],
  "src/shared/**": ["@/features/*", "@/data/*", "@/ui/*", "@/app/*"],
  "app/**": ["@/data/*"], // screens never touch the data layer directly
};
 
export default tseslint.config(
  ...Object.entries(layerRules).map(([files, patterns]) => ({
    files: [files],
    rules: {
      "no-restricted-imports": [
        "error",
        {
          patterns: patterns.map((group) => ({
            group: [group],
            message: `${files} may not import from this layer. Route it through shared instead.`,
          })),
        },
      ],
    },
  })),
);

Write the message carefully. Six months from now you will not remember why the rule exists, and that error string becomes the only design document that survives. In practice this mattered more than I expected: without a message, developers (me included) reach for // eslint-disable-next-line. With one, they reach for the correct import.

ESLint alone is not enough, though. A relative escape like ../../data/client from app/screens/Home.tsx can slip past a pattern, and circular dependencies are invisible to a rule that only sees one line at a time.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
The baseline technique that froze 214 legacy violations while forcing new ones to zero
Working ESLint zone config and dependency-cruiser rules, including the $1 back-reference for cross-feature bans
Measured cost: 38 seconds of CI time, 17 circular dependencies removed in three weeks
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Dev Tools2026-06-22
When a Poisoned Cache Crashes Your App on Every Launch — Designing a Safe-Mode Boot Your Users Can Escape On Their Own
When a persisted cache goes bad and the app crashes at the same spot on every launch, the only option left to the user is to reinstall. This article designs a safe-mode boot for Expo (React Native): the app counts its own early crashes, confirms a launch only once it becomes interactive, and resets just the dangerous state in graduated steps.
Dev Tools2026-06-15
Drawing the Line Between Rork Max's Swift Output and the Expo Build
Rork Max now generates native Swift, while the standard Rork keeps producing Expo (React Native) apps. Here is how to split responsibilities between the two engines inside a single app business, viewed from real maintenance cost.
Dev Tools2026-07-08
Building a Segmented Control With a Sliding Indicator in Reanimated
The stock segmented control looks out of place on Android. Here is a custom Reanimated component that measures each segment and slides the indicator, with complete code plus UI-thread rendering, accessibility, and RTL handling from real shipping notes.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →