Architecture: MVC / MVP / MVVM (Java)

Architecture is about organizing code so teams can add features safely, fix bugs quickly, and test logic easily. On Android, MVVM (Model–View–ViewModel) is a great default because it fits the platform’s lifecycles (Activity/Fragment), encourages a separation of concerns, and works seamlessly with ViewModel + LiveData.

At a Glance

MVC

Activity = Controller + View (often “Massive Activity”)

MVP

Presenter holds logic, View interface; lifecycle wiring needed

MVVM

ViewModel holds state & business logic; View observes it

View (Activity/Fragment) XML / Views / Adapters ViewModel UI state, commands, LiveData Domain & Data UseCases / Repository / DB / API Observe (LiveData) Events (clicks) Requests
Less coupling
UI & logic are decoupled via observers.
Lifecycle aware
ViewModel survives rotation.
Test-friendly
Business logic in plain Java.

Comparison Table

AspectMVCMVPMVVM
UI State holderActivity/FragmentPresenterViewModel (LiveData)
CommunicationDirect method callsView ↔ Presenter (interfaces)View observes ViewModel
Rotation handlingManual save/restorePresenter survives? usually noViewModel survives rotation
BoilerplateLow but messyMedium (interfaces)Low–Medium (Jetpack helps)
TestabilityHard (UI mixed)GoodGreat (logic in VM/UseCases)
Recommended for Android✅ (legacy)✅✅ (default)

Simple MVVM Structure (Java)

ui/ → Activities & Fragments (renders state, forwards user events) ui/viewmodel/ → ViewModel classes (state + intents to Domain/Data) domain/ → (Optional) UseCases (pure business rules) data/ → Repository + DataSources (network, database, cache)

Model & Repository

// model/User.java
public class User {
  public final String id;
  public final String name;
  public User(String id, String name){ this.id=id; this.name=name; }
}

// data/UserRepository.java
public interface UserRepository {
  Result<User> getUserById(String id); // sync example for brevity
}

// data/Result.java (generic success/error wrapper)
public abstract class Result<T> {
  public static final class Success<T> extends Result<T> { public final T data; public Success(T d){data=d;} }
  public static final class Error<T> extends Result<T> { public final Throwable error; public Error(Throwable e){error=e;} }
}

ViewModel (holds UI state)

// ui/viewmodel/ProfileViewModel.java
public class ProfileViewModel extends ViewModel {
  private final UserRepository repo;
  private final MutableLiveData<Boolean> loading = new MutableLiveData<>(false);
  private final MutableLiveData<User> user = new MutableLiveData<>();
  private final MutableLiveData<String> error = new MutableLiveData<>();

  public ProfileViewModel(UserRepository r){ this.repo = r; }

  public LiveData<Boolean> getLoading(){ return loading; }
  public LiveData<User> getUser(){ return user; }
  public LiveData<String> getError(){ return error; }

  public void load(String id) {
    loading.setValue(true);
    // pretend background thread; in real apps use Executor/Retrofit callback
    new Thread(() -> {
      Result<User> res = repo.getUserById(id);
      if (res instanceof Result.Success) {
        User u = ((Result.Success<User>) res).data;
        user.postValue(u);
      } else {
        error.postValue(((Result.Error<User>) res).error.getMessage());
      }
      loading.postValue(false);
    }).start();
  }
}

Activity observes state

// ui/ProfileActivity.java
public class ProfileActivity extends AppCompatActivity {
  ProfileViewModel vm;

  @Override protected void onCreate(Bundle s) {
    super.onCreate(s);
    setContentView(R.layout.activity_profile);

    TextView tvName = findViewById(R.id.tvName);
    ProgressBar pb = findViewById(R.id.progress);
    TextView tvError = findViewById(R.id.tvError);

    vm = new ViewModelProvider(this, new ViewModelProvider.Factory() {
      @NonNull @Override
      public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
        return (T) new ProfileViewModel(new UserRepositoryImpl()); // basic DI
      }
    }).get(ProfileViewModel.class);

    vm.getLoading().observe(this, show -> pb.setVisibility(show ? View.VISIBLE : View.GONE));
    vm.getUser().observe(this, u -> tvName.setText(u.name));
    vm.getError().observe(this, e -> tvError.setText(e));

    vm.load("42");
  }
}
Explanation: View (Activity) doesn’t fetch data or hold business logic. It renders state from the ViewModel and forwards events (like button clicks → vm.load()).

Why MVVM Is a Great Default on Android

  • Lifecycle-friendly: ViewModel survives configuration changes. LiveData stops/resumes automatically.
  • Clear boundaries: UI code (View) stays simple; business rules live in ViewModel/UseCases.
  • Scales well: Teams can work on UI and domain layers independently.
  • Testable: ViewModel is plain Java; easy to unit test without Android framework.
  • Less glue: Compared to MVP, fewer interfaces and no manual attach/detach of Presenters.

Maintainability (higher is better)

MVC
MVP
MVVM

* Conceptual comparison

Boilerplate (lower is better)

MVC
MVP
MVVM

Clean Architecture Layers (Optional but Helpful)

LayerResponsibilityContainsDepends On
UIRender state, forward eventsActivities/Fragments/AdaptersViewModel
PresentationState + logicViewModel, MappersDomain (use cases) or Repository
DomainBusiness rulesUseCases, EntitiesPure Java (no Android)
DataAccess data sourcesRepository, DAOs, API servicesNetworking/DB libs

Error Handling & Result Types

Expose UI-friendly state: Loading, Success(Data), Error(message). Either use a sealed-style class or multiple LiveData.

// ui/common/ViewState.java
public abstract class ViewState<T> {
  public static final class Loading<T> extends ViewState<T> {}
  public static final class Success<T> extends ViewState<T> { public final T data; public Success(T d){data=d;} }
  public static final class Error<T> extends ViewState<T> { public final String message; public Error(String m){message=m;} }
}

Threading Strategy

  • Do network/DB work off the main thread (Executor, Retrofit callbacks).
  • Push results to LiveData via postValue() from background threads.
  • Transform data in ViewModel (formatting, mapping) before reaching the UI.

Dependency Injection (Dagger 2 – Java)

DI helps swap implementations (e.g., FakeRepository for tests). For Java projects, Dagger 2 is common.

// Example: provide UserRepository
@Module
class DataModule {
  @Provides UserRepository provideRepo(){ return new UserRepositoryImpl(); }
}

Testing MVVM

LayerTest TypeWhat to Assert
ViewModelUnitLiveData emits Loading → Success/Error; mapping logic
UseCase (Domain)UnitBusiness rules, edge cases
RepositoryUnit/IntegrationCorrect source chosen (cache vs network)
UIInstrumentationViews render given state; navigation works

Common Pitfalls (and Fixes)

  • Fat ViewModel: Move business rules to UseCases; keep ViewModel focused on UI state/orchestration.
  • Leaking Activity: Don’t hold Activity/Context in long-lived objects; pass Application context if needed.
  • Mutable state exposed: Expose LiveData (read-only) to the View, keep MutableLiveData private.
  • UI logic in Repository: Keep repositories source-agnostic; format in ViewModel.

When MVVM Might Be Overkill

  • Very small, one-screen demo apps.
  • Throwaway prototypes. (But MVVM is still quick with templates.)

Hands-On Checklist

  1. Create a SearchViewModel with LiveData<ViewState<List<Item>>>.
  2. On search click: emit Loading → query repository → emit Success or Error.
  3. UI renders loading spinner, list, or error message based on state.
  4. Write a unit test stubbing repository to return fake data and verify state sequence.
User taps "Search" └─ View forwards event → ViewModel.search(q) └─ ViewModel posts Loading └─ Repository fetches data (network/db) └─ ViewModel posts Success(data) / Error(msg) └─ View observes & renders