UTA DevHub
Guides

Project Structure Migration Guide

Step-by-step guide for migrating to the hybrid architecture with domain-based business logic and feature-based UI organization.

Project Structure Migration Guide

Overview

This guide provides practical implementation steps for migrating from the legacy project structure to the new hybrid architecture that combines feature-based UI organization with domain-based business logic. It offers concrete examples, code snippets, and troubleshooting tips to help developers successfully transition existing code and implement new features according to the architectural guidelines.

When To Use

Use this guide when:

  • Starting development on a new feature that should follow the hybrid architecture
  • Refactoring existing code to align with the new architectural patterns
  • Extracting domain logic currently embedded within UI components
  • Creating reusable domain services that can be used across multiple features
  • Resolving circular dependencies caused by incorrect code organization

Implementation Patterns

Migrating Existing Code

Analyze the Current Code Structure

Before migrating, analyze your code to identify:

  • Business domain logic currently mixed with UI
  • API calls that should be in domain services
  • Shared utilities that are truly generic vs. domain-specific
  • Circular dependencies that need resolution
// Example: UI component with embedded domain logic to refactor
// ProductList.tsx
const ProductList = () => {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(false);
  
  // Domain logic mixed with UI - this should be extracted
  const fetchProducts = async () => {
    setLoading(true);
    try {
      const response = await axios.get('/api/products');
      setProducts(response.data);
    } catch (error) {
      console.error('Failed to fetch products', error);
    } finally {
      setLoading(false);
    }
  };
  
  useEffect(() => {
    fetchProducts();
  }, []);
  
  // Rendering logic...
}

Extract Domain Logic

Create a new domain directory structure and move business logic there:

mkdir -p src/core/domains/products
touch src/core/domains/products/api.ts
touch src/core/domains/products/types.ts
touch src/core/domains/products/hooks.ts

Define domain types:

// src/core/domains/products/types.ts
export interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
  imageUrl?: string;
}
 
export interface ProductFilters {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
}

Create API service methods:

// src/core/domains/products/api.ts
import { apiClient } from '@/core/shared/api/client';
import type { Product, ProductFilters } from './types';
 
export const getProducts = async (filters?: ProductFilters): Promise<Product[]> => {
  const response = await apiClient.get('/products', { params: filters });
  return response.data;
};
 
export const getProduct = async (id: string): Promise<Product> => {
  const response = await apiClient.get(`/products/${id}`);
  return response.data;
};

Create Domain Query Hooks

Implement React hooks that use TanStack Query to manage the data:

// src/core/domains/products/hooks.ts
import { useQuery } from '@tanstack/react-query';
import * as productsApi from './api';
import type { Product, ProductFilters } from './types';
 
// Query keys
const PRODUCTS_KEY = 'products';
 
export const useProducts = (filters?: ProductFilters) => {
  return useQuery({
    queryKey: [PRODUCTS_KEY, filters],
    queryFn: () => productsApi.getProducts(filters)
  });
};
 
export const useProduct = (id: string) => {
  return useQuery({
    queryKey: [PRODUCTS_KEY, id],
    queryFn: () => productsApi.getProduct(id),
    enabled: !!id
  });
};

Update UI Components

Refactor UI components to use the domain hooks:

// src/features/product-catalog/screens/ProductListScreen.tsx
import { useProducts } from '@/core/domains/products/hooks';
import type { ProductFilters } from '@/core/domains/products/types';
 
const ProductListScreen = () => {
  const [filters, setFilters] = useState<ProductFilters>({});
  
  // Use domain hook instead of local state + fetch
  const { data: products, isLoading, error } = useProducts(filters);
  
  // Filter handling logic...
  
  // UI rendering...
  return (
    <View>
      {isLoading ? (
        <LoadingSpinner />
      ) : error ? (
        <ErrorMessage error={error} />
      ) : (
        <ProductGrid products={products || []} />
      )}
    </View>
  );
};

Creating New Features With Hybrid Architecture

Plan Domain and Feature Boundaries

Identify the domains your feature will interact with and determine what new domain entities might be needed:

Feature: Shopping Cart

Domains needed:
- products (existing): Product information, pricing
- cart (new): Cart items, calculation logic
- user (existing): User information for checkout

Create Domain Logic First

Always start by implementing the domain logic before UI components:

// src/core/domains/cart/types.ts
import type { Product } from '@/core/domains/products/types';
 
export interface CartItem {
  productId: string;
  quantity: number;
  productSnapshot?: Partial<Product>;
}
 
export interface Cart {
  items: CartItem[];
  subtotal: number;
  tax: number;
  total: number;
}
// src/core/domains/cart/store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Cart, CartItem } from './types';
 
interface CartState {
  cart: Cart;
  addItem: (item: CartItem) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
}
 
export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      cart: {
        items: [],
        subtotal: 0,
        tax: 0,
        total: 0,
      },
      
      // Cart methods implementation...
      addItem: (item) => {
        // Implementation
      },
      
      removeItem: (productId) => {
        // Implementation
      },
      
      updateQuantity: (productId, quantity) => {
        // Implementation
      },
      
      clearCart: () => {
        // Implementation
      },
    }),
    {
      name: 'cart-storage',
    }
  )
);

Create Domain Hooks

Create hooks that expose domain functionality to UI components:

// src/core/domains/cart/hooks.ts
import { useCartStore } from './store';
import type { CartItem } from './types';
 
export const useCart = () => {
  const { cart, addItem, removeItem, updateQuantity, clearCart } = useCartStore();
  
  const totalItems = cart.items.reduce((sum, item) => sum + item.quantity, 0);
  
  return {
    cart,
    totalItems,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    isEmpty: cart.items.length === 0,
  };
};

Build Feature UI Components

Now implement the feature UI components:

// src/features/shopping-cart/components/CartItem/CartItem.tsx
import { useCart } from '@/core/domains/cart/hooks';
import { formatCurrency } from '@/core/shared/utils/number';
 
type CartItemProps = {
  productId: string;
  quantity: number;
  name: string;
  price: number;
};
 
const CartItem = ({ productId, quantity, name, price }: CartItemProps) => {
  const { updateQuantity, removeItem } = useCart();
  
  return (
    <View style={styles.container}>
      <Text style={styles.name}>{name}</Text>
      <Text style={styles.price}>{formatCurrency(price)}</Text>
      {/* Quantity controls */}
      {/* Remove button */}
    </View>
  );
};
// src/features/shopping-cart/screens/CartScreen/CartScreen.tsx
import { useCart } from '@/core/domains/cart/hooks';
import { CartItem } from '../../components/CartItem';
 
const CartScreen = () => {
  const { cart, totalItems, clearCart } = useCart();
  
  if (cart.isEmpty) {
    return <EmptyCartMessage />;
  }
  
  return (
    <View style={styles.container}>
      <FlatList
        data={cart.items}
        renderItem={({ item }) => (
          <CartItem
            productId={item.productId}
            quantity={item.quantity}
            name={item.productSnapshot?.name || ''}
            price={item.productSnapshot?.price || 0}
          />
        )}
        keyExtractor={(item) => item.productId}
      />
      
      <CartSummary
        subtotal={cart.subtotal}
        tax={cart.tax}
        total={cart.total}
      />
      
      <Button
        title="Proceed to Checkout"
        onPress={handleCheckout}
      />
    </View>
  );
};

Common Challenges

Circular Dependencies

Problem: You have modules that import from each other, creating a circular dependency.

Solution:

  1. Identify the cycle in the dependency graph
  2. Extract shared types to a separate file
  3. Use interfaces instead of direct imports when possible
// Before: Circular dependency between products and cart
 
// core/domains/products/types.ts
import { CartItem } from '@/core/domains/cart/types'; // Circular!
 
// core/domains/cart/types.ts  
import { Product } from '@/core/domains/products/types'; // Circular!
 
// After: Resolving with proper interfaces
 
// core/domains/products/types.ts
export interface Product {
  id: string;
  name: string;
  price: number;
}
 
// core/domains/cart/types.ts
export interface CartItem {
  productId: string;
  quantity: number;
  productSnapshot?: {
    name?: string;
    price?: number;
  };
}

Domain Boundary Decisions

Problem: Unsure where to place code that seems to belong to multiple domains.

Solution:

  • Identify the primary responsibility of the code
  • Consider which domain would be most affected by changes to this code
  • If truly shared, create a new domain or place in appropriate shared folder

Refactoring Large Components

Problem: Large components with mixed concerns are difficult to migrate.

Solution:

  1. Start by extracting pure UI components
  2. Move data fetching to custom hooks
  3. Extract business logic to domain services
  4. Finally, reconnect using the clean architecture pattern

Performance Considerations

Minimize Re-renders

  • Use selector functions when accessing domain state to prevent unnecessary re-renders
  • Consider memoization for expensive calculations
  • Keep state updates granular to avoid cascading re-renders
// Good: Using selectors for performance
const totalItems = useCartStore(state => state.cart.items.reduce(
  (sum, item) => sum + item.quantity, 0
));
 
// Bad: Will cause re-renders on any cart change
const { cart } = useCartStore();
const totalItems = cart.items.reduce((sum, item) => sum + item.quantity, 0);

Lazy Loading Domains

For large applications, consider implementing lazy loading of domain logic:

// Dynamic import example
const ProductDetail = () => {
  const [productService, setProductService] = useState(null);
  
  useEffect(() => {
    // Dynamically import the domain logic when needed
    import('@/core/domains/products/service').then(module => {
      setProductService(module);
    });
  }, []);
  
  if (!productService) return <Loading />;
  
  // Rest of component using productService
};

Examples

Full Migration Example: User Profile Feature

This example shows how to migrate a user profile feature from the old structure to the new hybrid architecture:

// Old structure: Everything in the feature folder
// features/profile/ProfileScreen.jsx
 
const ProfileScreen = () => {
  const [profile, setProfile] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetchUserProfile();
  }, []);
  
  const fetchUserProfile = async () => {
    setLoading(true);
    try {
      const response = await fetch('/api/user/profile');
      const data = await response.json();
      setProfile(data);
    } catch (error) {
      console.error(error);
    } finally {
      setLoading(false);
    }
  };
  
  const updateProfile = async (updates) => {
    try {
      const response = await fetch('/api/user/profile', {
        method: 'PUT',
        body: JSON.stringify(updates),
      });
      const updatedProfile = await response.json();
      setProfile(updatedProfile);
    } catch (error) {
      console.error(error);
    }
  };
  
  // Rendering logic...
};

After migration:

// Domain logic: core/domains/users/types.ts
export interface UserProfile {
  id: string;
  name: string;
  email: string;
  avatar?: string;
  preferences: {
    notifications: boolean;
    theme: 'light' | 'dark' | 'system';
  };
}
 
// Domain logic: core/domains/users/api.ts
import { apiClient } from '@/core/shared/api/client';
import type { UserProfile } from './types';
 
export const getUserProfile = async (): Promise<UserProfile> => {
  const response = await apiClient.get('/user/profile');
  return response.data;
};
 
export const updateUserProfile = async (updates: Partial<UserProfile>): Promise<UserProfile> => {
  const response = await apiClient.put('/user/profile', updates);
  return response.data;
};
 
// Domain logic: core/domains/users/hooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as usersApi from './api';
import type { UserProfile } from './types';
 
const USER_PROFILE_KEY = 'userProfile';
 
export const useUserProfile = () => {
  return useQuery({
    queryKey: [USER_PROFILE_KEY],
    queryFn: usersApi.getUserProfile
  });
};
 
export const useUpdateUserProfile = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: usersApi.updateUserProfile,
    onSuccess: (data) => {
      queryClient.setQueryData([USER_PROFILE_KEY], data);
    }
  });
};
 
// UI component: features/profile/screens/ProfileScreen/ProfileScreen.tsx
import { useUserProfile, useUpdateUserProfile } from '@/core/domains/users/hooks';
 
const ProfileScreen = () => {
  const { data: profile, isLoading, error } = useUserProfile();
  const { mutate: updateProfile, isPending: isUpdating } = useUpdateUserProfile();
  
  const handleUpdateProfile = (updates) => {
    updateProfile(updates);
  };
  
  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  
  // Rendering logic using profile data and handleUpdateProfile...
};

This migration:

  1. Separates domain logic (data types, API calls) from UI concerns
  2. Uses TanStack Query for data fetching and caching
  3. Makes testing easier by isolating business logic
  4. Improves reusability of the user profile functionality