I'm a huge movie fan, and I've decided to combine my passion for cinema with my love for building well-architected systems to practice my React Native skills. That's how Premiere Night was born.
I've worked with Clean Architecture before. In fact, I built another React Native project with even more layers and complexity, but it was on an older version of React Native. I wanted to challenge myself to apply these same architectural principles to a modern React Native 0.82 setup with the latest tools - in a more simple way.
But this isn't just a "look at my code" post. I made mistakes, learned valuable lessons, and discovered what actually works (and what's overkill) when building production-ready React Native apps. Whether you're a solo developer or working in a team, I hope my experience helps you make better architectural decisions.
What We're Building
Premiere Night is a movie discovery app that lets users:
- Browse popular, top-rated, and upcoming movies
- Search movies in real-time with debouncing (500ms)
- View detailed movie information
- Save favorites to an offline watchlist
Tech Stack:
- React Native 0.82 (bare workflow, no Expo)
- TypeScript 5.8 with strict mode
- Redux Toolkit 2.10 for state management
- React Navigation 7.x
- AsyncStorage for local persistence
- Jest + Testing Library (289 passing tests)
🎬 Try Android Demo | 📱 Try iOS Demo | 💻 View Source
Clean Architecture: Breaking It Down
If you've never worked with Clean Architecture before, don't worry! I'll explain it in a way that actually makes sense (I wish someone had done this for me when I started).
The core idea is simple: separate your app into layers where inner layers don't know about outer layers. Think of it like a building where the foundation doesn't care about the paint color on the walls.
If you want to dive deeper into Clean Architecture, I've written guides on the topic:
- Clean Architecture: The Concept Behind the Code - Understanding the core principles
- Clean Architecture: Applying with React - Practical implementation with React
This post focuses specifically on React Native implementation, but those articles will give you the foundational knowledge if you're new to these concepts.
┌─────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ (UI, Screens, Components, Hooks) │
└──────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ DATA LAYER │
│ (Repositories, DTOs, Mappers) │
└────────┬─────────────────────┬──────────┘
↓ ↓
┌──────────────────┐ ┌──────────────────┐
│ DOMAIN LAYER │ │ INFRA LAYER │
│ (Entities, │ │ (API, Redux, │
│ Interfaces) │ │ Storage) │
└──────────────────┘ └──────────────────┘
Layer Responsibilities
Domain Layer (Pure TypeScript):
- Defines business entities (
Movie,MovieDetails) - Defines repository interfaces (
IMovieRepository) - Zero external dependencies
- Framework-agnostic
Infrastructure Layer:
- HTTP client (TMDb API)
- Redux store configuration
- State slices (movies, search, watchlist, details)
- AsyncStorage integration
Data Layer:
- Repository implementations (
MovieRepositoryImpl) - DTOs matching API responses
- Mappers transforming DTOs to domain entities
- Data utilities (image URLs, etc.)
Presentation Layer:
- React components and screens
- Custom hooks for business logic
- Navigation configuration
- UI styles and themes
Layer Deep Dive
1. Domain Layer: Pure Business Logic
Purpose: Define what the app does, not how.
export interface Movie {
id: number;
title: string;
overview: string;
posterPath: string | null;
releaseDate: string;
voteAverage: number;
}
export interface IMovieRepository {
getPopularMovies(params?: MovieListParams): Promise<PaginatedResponse<Movie>>;
getMovieDetails(params: MovieDetailsParams): Promise<MovieDetails>;
searchMovies(params: MovieSearchParams): Promise<PaginatedResponse<Movie>>;
}
Key Benefits:
- No React, no Redux, no external libraries
- Easy to understand business rules
- Reusable across platforms (web, mobile)
- Perfect for documentation
2. Infrastructure Layer: External Systems
Purpose: Handle communication with external dependencies.
class ApiClient {
private baseURL: string;
private apiKey: string;
async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
const url = this.buildUrl(endpoint, params);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
}
export const apiClient = new ApiClient();
Why Redux Toolkit?
- Excellent TypeScript support with full type inference
- Less boilerplate (no action constants, simplified reducers)
- Built-in thunk middleware for async actions
- Immer integration for immutable updates
3. Data Layer: Transformation Hub
Purpose: Transform external data to domain models.
import { IMovieRepository } from '@domain/repositories/MovieRepository';
import { apiClient } from '@infra/api/ApiClient';
import { MovieMapper } from '../mappers/MovieMapper';
export class MovieRepositoryImpl implements IMovieRepository {
async getPopularMovies(
params?: MovieListParams
): Promise<PaginatedResponse<Movie>> {
// 1. Fetch from API
const response = await apiClient.get<PaginatedResponseDTO<MovieDTO>>(
'movie/popular',
{ page: params?.page, language: params?.language || 'en-US' }
);
// 2. Transform DTO to Domain
return MovieMapper.toPaginatedDomain(response, MovieMapper.toDomain);
}
}
export const movieRepository = new MovieRepositoryImpl();
The Mapper Pattern:
export class MovieMapper {
static toDomain(dto: MovieDTO): Movie {
return {
id: dto.id,
title: dto.title,
overview: dto.overview,
posterPath: dto.poster_path, // snake_case → camelCase
releaseDate: dto.release_date,
voteAverage: dto.vote_average,
};
}
}
Why Mappers?
- API contracts change, domain models don't
- Centralized transformation logic
- Easy to test in isolation
- Type-safe conversions
4. Presentation Layer: User Interface
Custom Hooks Pattern:
import { useAppDispatch, useAppSelector } from '@infra/store/hooks';
import { fetchPopularMovies, selectMoviesState } from '@infra/store/movies';
export const useMovies = () => {
const dispatch = useAppDispatch();
const moviesState = useAppSelector(selectMoviesState);
useEffect(() => {
dispatch(fetchPopularMovies({ page: 1 }));
}, [dispatch]);
const loadMore = useCallback(() => {
dispatch(fetchPopularMovies({
page: moviesState.popular.currentPage + 1,
append: true
}));
}, [dispatch, moviesState.popular.currentPage]);
return {
movies: moviesState.popular.data,
loading: moviesState.popular.loading,
error: moviesState.popular.error,
loadMore,
};
};
Benefits:
- Components focus solely on rendering
- Business logic is reusable and testable
- Easy to mock in tests
- Hooks can be tested independently of UI
Implementing a Feature: Movie Search
Let's implement the search feature step-by-step to see the architecture in action.
Step 1: Define Domain Contract
export interface MovieSearchParams {
query: string;
page?: number;
language?: string;
year?: number;
}
export interface IMovieRepository {
searchMovies(params: MovieSearchParams): Promise<PaginatedResponse<Movie>>;
}
Step 2: Implement Repository
async searchMovies(
params: MovieSearchParams
): Promise<PaginatedResponse<Movie>> {
const response = await apiClient.get<PaginatedResponseDTO<MovieDTO>>(
'search/movie',
{
query: params.query,
page: params.page,
language: params.language || 'en-US',
year: params.year,
}
);
return MovieMapper.toPaginatedDomain(response, MovieMapper.toDomain);
}
Step 3: Create Redux Slice
const searchSlice = createSlice({
name: 'search',
initialState: {
query: '',
results: [],
loading: false,
hasSearched: false,
},
reducers: {
setSearchQuery: (state, action: PayloadAction<string>) => {
state.query = action.payload;
},
clearSearch: (state) => {
state.query = '';
state.results = [];
state.hasSearched = false;
},
},
extraReducers: (builder) => {
builder
.addCase(searchMovies.pending, (state) => {
state.loading = true;
})
.addCase(searchMovies.fulfilled, (state, action) => {
state.loading = false;
state.results = action.payload.results;
state.hasSearched = true;
});
},
});
Step 4: Create Custom Hook with Debounce
This is where the magic happens - debouncing reduces API calls by ~80%.
export const useMovieSearch = (debounceTime = 500) => {
const dispatch = useAppDispatch();
const searchState = useAppSelector(selectSearchState);
const [localQuery, setLocalQuery] = useState('');
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleQueryChange = useCallback((query: string) => {
setLocalQuery(query); // Immediate UI update
dispatch(setSearchQuery(query));
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
if (query.trim().length === 0) {
dispatch(clearSearch());
return;
}
// Debounced API call
if (query.trim().length >= 2) {
debounceTimer.current = setTimeout(() => {
dispatch(searchMovies({ query: query.trim(), page: 1 }));
}, debounceTime);
}
}, [dispatch, debounceTime]);
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, []);
return {
query: localQuery,
results: searchState.results,
loading: searchState.loading,
onQueryChange: handleQueryChange,
};
};
Why Debounce?
- Reduces API calls by ~80% during typing
- Better performance and UX
- Respects TMDb API rate limits
- Immediate visual feedback, delayed API call
Step 5: Use in Component
export const HomeScreen = () => {
const { query, results, loading, onQueryChange } = useMovieSearch();
return (
<View>
<SearchBar
value={query}
onChangeText={onQueryChange}
placeholder="Search movies..."
/>
{loading && <ActivityIndicator />}
{results.length > 0 && <SearchResults movies={results} />}
</View>
);
};
Why This Architecture Changed My Life (Okay, My Codebase)
Let me be honest, when I started, I thought "this is way too much work". But after finishing the project, I can clearly see the benefits. Here's what I gained:
1. Testing Became More Enjoyable
Each layer can be tested independently, which means:
- No more "change one thing, break everything" moments
- Tests run faster because you're not booting up the whole app
- 99.77% coverage wasn't painful—it was achievable!
// Test mapper without API
describe('MovieMapper', () => {
it('should map DTO to domain', () => {
const dto: MovieDTO = {
id: 1,
title: 'Test Movie',
poster_path: '/test.jpg',
};
const result = MovieMapper.toDomain(dto);
expect(result).toEqual({
id: 1,
title: 'Test Movie',
posterPath: '/test.jpg',
});
});
});
// Test repository with mocked API
jest.mock('@infra/api/ApiClient');
describe('MovieRepositoryImpl', () => {
it('should fetch popular movies', async () => {
const mockResponse = { results: [/* ... */] };
(apiClient.get as jest.Mock).mockResolvedValue(mockResponse);
const result = await movieRepository.getPopularMovies();
expect(apiClient.get).toHaveBeenCalledWith('movie/popular', expect.any(Object));
expect(result.results).toHaveLength(mockResponse.results.length);
});
});
// Test hook without rendering UI
const { result } = renderHook(() => useMovieSearch(), {
wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
});
act(() => {
result.current.onQueryChange('Avatar');
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
Test Distribution:
- Unit tests: ~180 (Mappers, utils, reducers)
- Integration tests: ~60 (Redux slices with thunks)
- Component tests: ~49 (React components and hooks)
2. Maintainability
- Clear boundaries: Each layer has a single responsibility
- Easy to find code: Predictable folder structure
- Type-safe refactoring: TypeScript catches breaking changes
- Self-documenting: Interfaces serve as documentation
3. Flexibility
Want to switch from Redux to Zustand?
- Only change the Infra layer
- Domain and Data layers stay the same
- Components use the same custom hooks
Want to add GraphQL?
- Create new repository implementation
- Keep domain entities unchanged
- Swap implementations via dependency injection
4. Scalability
Adding new features is straightforward:
- Define domain contract
- Implement repository
- Create Redux slice (if needed)
- Build UI components
Each step is independent and testable.
Let's Talk About The Elephant in The Room
Is this overkill? Sometimes, yes. Let me be brutally honest about the downsides:
1. You'll Create A LOT of Files
Real talk:
- I spent 30% more time on setup than a simpler approach
- For every feature, I created DTOs, Mappers, Interfaces, Tests...
But, in my opinion, here's when it's worth it:
- Medium to large projects
- Team of 2+ developers
- Long-term maintenance expected
- High test coverage required
When It's Overkill:
- Quick prototypes
- Simple CRUD apps
- Solo weekend projects
2. Learning Curve
Team members need to understand:
- Layer boundaries and dependencies
- Repository pattern
- DTO vs Domain entities
- When to create new abstractions
Solution:
- Good documentation (like ARCHITECTURE.md)
- Code reviews to enforce patterns
- Pair programming for onboarding
3. Initial Overhead
First feature takes longer:
- Set up folder structure
- Configure TypeScript path aliases
- Create base interfaces
- Set up testing infrastructure
But subsequent features are faster:
- Copy-paste and adapt existing patterns
- Reuse mappers, utilities, components
- Clear guidelines reduce decision fatigue
Key Concepts Explained
Repository Pattern
What: Interface that abstracts data access logic.
Why:
- Decouple business logic from data source
- Easy to mock in tests
- Can swap implementations (REST → GraphQL)
Example:
// Domain defines what we need
interface IMovieRepository {
getPopularMovies(): Promise<Movie[]>;
}
// Data implements how we get it
class MovieRepositoryImpl implements IMovieRepository {
async getPopularMovies(): Promise<Movie[]> {
const response = await apiClient.get('movie/popular');
return response.results.map(MovieMapper.toDomain);
}
}
// Tests can use a fake implementation
class FakeMovieRepository implements IMovieRepository {
async getPopularMovies(): Promise<Movie[]> {
return [{ id: 1, title: 'Test Movie', /* ... */ }];
}
}
DTO (Data Transfer Object)
What: Object that matches external API shape.
Why:
- API uses snake_case, we use camelCase
- API might change, our domain shouldn't
- Separates external contract from internal model
Example:
// DTO: Matches API response
interface MovieDTO {
id: number;
title: string;
poster_path: string | null; // snake_case from API
vote_average: number;
}
// Domain: Our internal model
interface Movie {
id: number;
title: string;
posterPath: string | null; // camelCase for TypeScript
voteAverage: number;
}
// Mapper bridges the gap
MovieMapper.toDomain(dto);
Redux Slice Pattern
What: Co-locate all Redux logic for a feature in one place.
Structure:
store/movies/
├── slice.ts # Reducers
├── thunks.ts # Async actions
├── selectors.ts # State queries
├── initialState.ts
└── tests/
Benefits:
- All related code in one place
- Easy to find and modify
- Clear feature boundaries
- Simple to test
Custom Hooks for Business Logic
What: Extract component logic into reusable hooks.
Why:
- Components focus on rendering
- Logic is reusable across components
- Easy to test without mounting components
- Better separation of concerns
Example:
// Hook handles business logic
const useMovies = () => {
const dispatch = useAppDispatch();
const movies = useAppSelector(selectPopularMovies);
useEffect(() => {
dispatch(fetchPopularMovies());
}, []);
return { movies };
};
// Component just renders
const MovieList = () => {
const { movies } = useMovies();
return <FlatList data={movies} />;
};
Results: By The Numbers
- 99.77% Statement Coverage
- 98.41% Branch Coverage
- 100% Function Coverage
- 289 Passing Tests
- 4 Clear Layers
- 0 Circular Dependencies
Questions You Might Be Asking
Q: "Is this overkill for my 5-screen app?"
A: Yes, probably. For small projects (< 10 screens), consider:
- Simple folder structure (components, screens, utils)
- React Query instead of Redux
- Fewer abstractions
Use Clean Architecture when:
- Team size > 2
- Expected lifetime > 6 months
- Complexity will grow
- High test coverage required
Q: "Does this work with Expo?"
A: Absolutely! I used bare React Native, but the architecture doesn't care. Just:
- Swap
@react-native-async-storagewithexpo-secure-storeif you want - Use Expo modules wherever you need them
- Keep the same layer structure
The beauty of Clean Architecture is that it's framework-agnostic!
Q: "How do you manage API keys securely?"
A: I use react-native-dotenv:
import { TMDB_API_KEY, TMDB_BASE_URL } from '@env';
const apiClient = new ApiClient({
baseURL: TMDB_BASE_URL,
apiKey: TMDB_API_KEY,
});
When to Use This Architecture
Great For:
- Production apps
- Team projects
- Apps with complex business logic
- High test coverage requirements
- Long-term maintenance
- Multiple data sources
Overkill For:
- Quick prototypes
- Solo weekend projects
- Simple CRUD apps
- Learning projects
- MVPs that might be thrown away
Conclusion
Building this app showed me that Clean Architecture isn’t about achieving perfection — it’s about making deliberate, well-reasoned trade-offs. What initially felt overwhelming gradually revealed its value. The test suite reached 99.77% coverage and, instead of feeling unattainable, it became a natural part of the workflow. Adding new features turned into a predictable, almost effortless process once the foundation was in place. Most importantly, refactoring stopped being risky—the architecture and tests consistently supported every change.
Resources
Premiere Night Project:
Learn More About Clean Architecture:
- Clean Architecture: The Concept Behind the Code - Core principles and theory
- Clean Architecture: Applying with React - Practical React implementation

