The Story Behind Tabaiba
In a world where social connections often feel superficial and dopamine-driven, Tabaiba emerges as a breath of fresh air. The concept is simple yet powerful: instead of endlessly swiping through profiles, users would receive three carefully selected connections through matching algorithms every Friday. This approach resonated deeply with me—it felt authentic, intentional, and human.
The Challenge
Building a mobile application that could deliver this experience required more than just programming skills. It needed an approach based on Domain-Driven Design and a feature-based architecture.
The Technical Journey
Architecture and Structure
The implementation focused on creating a structure that reflected the problem domain. We organized the code into independent modules, each representing a specific application capability:
core/ ├── onboarding/ # Onboarding feature │ ├── application/ # Use cases and state management │ ├── domain/ # Domain models and interfaces │ ├── infrastructure/ # Repository implementations and coupled code │ └── delivery/ # UI Components ├── auth/ # Authentication feature └── i18n/ # Internationalization
This structure wasn't just about organization—it was about creating a clear mental model that would guide us through the development process.
Technology Stack
The technology choices were intentional and purposeful:
-
React Native with Expo
- Cross-platform development while maintaining native feel
- Expo ecosystem for faster development
- Ensuring consistent experience across devices
-
- Type safety and better development experience
- Early error detection
- Maintainable and self-documented codebase
-
- Consistent design system with Tailwind
- Rapid UI development without sacrificing performance
- Creating a consistent design system
-
- Complete internationalization with multilanguage support
- Automatic device language detection
- Region-based pluralization and date formatting
- Variable interpolation in translations
-
- PostgreSQL with Row Level Security
- Authentication system with multiple providers
- Granular security policies
- Real-time subscriptions
- Automatic TypeScript type generation
-
- Runtime data validation
- Automatic type inference
- Data transformation and normalization
- Form and API request validation
Design Patterns
Onboarding is one of the most critical moments in the user experience. We needed to solve several challenges:
- Flow Complexity: The process had to collect essential user information without being overwhelming
- Flexibility: Some steps needed to be mandatory while others optional, allowing users to customize their experience
- Maintainability: The flow needed to be easy to modify, as we expected to iterate based on user feedback
- State: We needed to robustly manage user progress, allowing them to resume where they left off
- User Experience: Navigation needed to be intuitive, with clear options to advance, go back, or skip steps
To address these challenges, we implemented several design patterns that provided us with an elegant and scalable solution:
Template Method Pattern
This pattern allows us to define the "skeleton" of an algorithm in a method, delegating some steps to subclasses. In our case, the base onboarding component provides a common structure for all steps, giving us several advantages:
- Visual Consistency: All steps maintain the same structure and style
- DRY (Don't Repeat Yourself): Common navigation and layout logic is defined once
- Flexibility: Each step can customize its content while maintaining the common structure
- Maintainability: Changes to the base structure automatically apply to all steps
interface StepProps extends PropsWithChildren { title: string; description: string; className?: string; onNext: () => void; onBack?: () => void; onSkip?: () => void; isOptional?: boolean; } export const BaseStep: FC<StepProps> = ({ title, description, children, onNext, onBack, onSkip, isOptional = false, }) => { return ( <View className="flex-1 justify-between p-4"> <View className="flex-1"> <Text className="text-2xl font-bold">{title}</Text> <Text className="text-gray-600 mt-2">{description}</Text> <View className="mt-8">{children}</View> </View> <View className="flex-row justify-between items-center"> <Button onPress={onBack} variant="ghost"> {t('common.back')} </Button> <View className="flex-row gap-2"> {isOptional && ( <Button onPress={onSkip} variant="ghost"> {t('common.skip')} </Button> )} <Button onPress={onNext}> {t('common.next')} </Button> </View> </View> </View> ); };
Domain Model Pattern
The domain model is the heart of our application. Following DDD principles and the Repository Pattern, we encapsulate all business logic in rich domain objects that:
- Ensure Integrity: Business rules are consistently applied
- Are Self-Validating: The model itself ensures data validity
- Provide a Clear API: Methods clearly express allowed operations
- Are Immutable: Preventing unwanted side effects
interface OnboardingStatus { completedSteps: StepId[] isFullyCompleted: boolean getNextStep(): StepId | null } interface OnboardingStatusValue { data: OnboardingData; completedSteps: StepId[]; skippedSkippableSteps: boolean; requiredStepsCompleted: boolean; skippableStepsCompleted: boolean; lastCompletedStep: StepId | null; } export class OnboardingStatus extends ValueObject<OnboardingStatusValue> { static create(params: OnboardingStatusValue): OnboardingStatus { return new OnboardingStatus(params); } toPrimitives(): OnboardingStatusValue { return { data: this.value.data, completedSteps: this.value.completedSteps, skippedSkippableSteps: this.value.skippedSkippableSteps, requiredStepsCompleted: this.value.requiredStepsCompleted, skippableStepsCompleted: this.value.skippableStepsCompleted, lastCompletedStep: this.value.lastCompletedStep, }; } isFullyCompleted(): boolean { return this.value.requiredStepsCompleted && this.value.skippableStepsCompleted; } isRequiredCompleted(): boolean { return this.value.requiredStepsCompleted; } hasSkippedSkippableSteps(): boolean { return this.value.skippedSkippableSteps; } getCompletedSteps(): StepId[] { return this.value.completedSteps; } getData(): OnboardingData { return this.value.data; } }
Command Pattern and Observer Pattern
The application layer acts as an orchestrator between the UI and domain. We use custom hooks and the Command pattern to:
- Separate Responsibilities: Business logic is separated from the UI
- Improve Testability: Each command can be tested in isolation
- Facilitate Reuse: Hooks encapsulate complex logic that can be reused
- Manage State: In a predictable and centralized way
export function useOnboarding() { const router = useRouter(); const pathname = usePathname(); const onboardingRepository = container.resolve<OnboardingRepository>( InjectionTokens.ONBOARDING_REPOSITORY ); const [onboardingStatus, setOnboardingStatus] = useState<OnboardingStatus | null>(null); useEffect(() => { loadOnboardingStatus(); }, []); const loadOnboardingStatus = async () => { const status = await onboardingRepository.getOnboardingStatus(); setOnboardingStatus(status); if (!pathname?.includes('onboarding')) { if (!status.isRequiredCompleted()) { router.push('/onboarding/required'); } else if (status.isFullyCompleted()) { router.push('/(tabs)'); } } }; const completeRequiredOnboarding = useCallback(async () => { await onboardingRepository.completeRequiredOnboarding(); }, [onboardingRepository]); const completeOptionalOnboarding = useCallback(async () => { await onboardingRepository.completeOnboarding(); router.push('/(tabs)'); }, [onboardingRepository, router]); return { onboardingStatus, completeRequiredOnboarding, completeOptionalOnboarding, }; } @injectable() export class SetNameCommand implements Command<SetNameParams, void> { constructor( @inject(InjectionTokens.ONBOARDING_REPOSITORY) private readonly onboardingRepository: OnboardingRepository ) {} async handle(params: SetNameParams): Promise<void> { await this.onboardingRepository.updateOnboardingStep(STEP_IDS.NAME, { name: params.name }); } }
Composite Pattern
UI components are built following the principle of controlled and decoupled components, using the Composite pattern to create hierarchical component structures. This approach provides us with:
- Reusable Components: Each component has a single responsibility
- Unidirectional Data Flow: Makes code more predictable and easier to debug
- Separation of Concerns: Presentation logic is separated from business logic
- Better Development Experience: Components are easy to understand and modify
export const RequiredOnboarding: FC = () => { const { completeRequiredOnboarding } = useOnboarding(); const [currentStepIndex, setCurrentStepIndex] = useState(0); const handleNext = () => { if (currentStepIndex === 0) { completeRequiredOnboarding(); setCurrentStepIndex(currentStepIndex + 1); } }; const handleBack = () => { setCurrentStepIndex(currentStepIndex - 1); }; const steps = [<NameStep key="name" />, <ContinueOrSkipStep key="continueOrSkip" />]; return ( <Page> <Wizard currentStepIndex={currentStepIndex} onNext={handleNext} onBack={handleBack} nextLabel={t(currentStepIndex === 0 ? 'common.next' : 'common.complete')} backLabel={t('common.back')} showBackButton={!isTransitionStep} hideDefaultButtons={isTransitionStep}> {steps} </Wizard> </Page> ); }; export const NameStep: FC = () => { const [name, setName] = useState(''); const useCase = container.resolve(UseCaseService); const handleNameChange = async (value: string) => { setName(value); await updateProfile(value); }; const updateProfile = async (name: string) => { useCase.execute(SetNameCommand, { name }); }; return ( <BaseStep title="What's your name?" description="Let us know how we should call you"> <View className="space-y-4"> <TextInput className="bg-gray-100 p-4 rounded-lg" placeholder="Your name" value={name} onChangeText={handleNameChange} /> </View> </BaseStep> ); };
Feature Implementation
Each feature follows a layered architecture that provides several benefits:
-
Domain Layer
- Business logic isolation
- Centralized and consistent business rules
- Rich and expressive domain model
- Data validation and encapsulation
-
Application Layer
- Use case orchestration
- Predictable state management
- Controlled side effects handling
- Clear separation of responsibilities
-
UI Layer
- Reusable and maintainable components
- Separation of presentation logic
- Consistent user experience
- Simplified testing
This layered architecture allows us to:
- Evolve each layer independently
- Facilitate automated testing
- Maintain a sustainable codebase
- Scale the development team efficiently
The Results
What emerged was more than just a mobile application—it was a well-crafted solution that balanced technical excellence with user experience. The codebase became a pleasure to work with, the architecture proved to be flexible and maintainable, and the user experience felt natural and engaging.
Conclusion
Tabaiba represents more than just another mobile application—it's a testament to the power of clean architecture, thoughtful design, and user-centered development. It's about creating something that not only works well but feels right to use. I'm very happy to have been able to collaborate in developing part of this application and want to thank Gorka Mañana for the opportunity.