MVVM with swiftUI
When learning SwiftUI, one of the first architectural patterns you'll encounter is MVVM (Model-View-ViewModel). In this tutorial, we'll build a simple weather application that consumes the OpenWeather API while applying MVVM, dependency injection, and protocol-oriented programming. By the end, you'll understand not only how to structure the project, but also why each layer exists.
This is the link for the OpenWeather API https://openweathermap.org/api.
What you'll learn
By the end of this tutorial you'll know how to:
- Structure a SwiftUI project using MVVM.
- Consume a REST API using async/await.
- Apply dependency injection using protocols.
- Display loading and error states.
- Keep Views focused only on UI.
This is how the data flow looks.
User taps "Search"
│
▼
┌──────────────┐
│ ContentView │
└──────┬───────┘
│
await fetchWeather()
│
▼
┌────────────────────┐
│ WeatherViewModel │
└─────────┬──────────┘
│
▼
WeatherServiceProtocol
│
▼
┌─────────────────┐
│ WeatherService │
└──────┬──────────┘
│
▼
OpenWeather API
Project Structure
WeatherApp
├── Configuration
│ └──AppConfig.swift
├── Models
│ ├── Main.swift
│ ├── Weather.swift
│ └── WeatherResponse.swift
├── Services
│ ├── WeatherService.swift
│ └── WeatherServiceProtocol.swift
├── ViewModels
│ └── WeatherViewModel.swift
└── Views
└── ContentView.swift
- Configuration contains application-wide constants such as the API key and base URLs.
- Models contains the data structures used to decode the API response.
- Services is responsible for networking and fetching data.
- ViewModels contains the presentation logic and exposes data to the UI.
- Views contains the SwiftUI interface.
On the AppConfig file, we are going to keep static info, just like the base URL, API key, etc
struct AppConfig {
static let apiKey = "YOUR API KEY"
static let baseGeoCodingAPIURL = "https://api.openweathermap.org/geo/1.0/direct?q="
static let baseURL = "https://api.openweathermap.org/data/2.5/weather?&units=metric&lat="
}
Designing the UI
Before implementing the networking layer, we'll start by designing the UI. Defining the interface first helps us identify exactly which data we need from the API, allowing us to create only the models required by the application.
The app is going to look something like
┌───────────────────────────────┐
│ Enter city Search │
├───────────────────────────────┤
│ │
│ Rain │
│ Moderate Rain │
│ │
│ 28.5° │
│ │
│ Feels Like Min Max │
│ 29.1° 25.0° 31.2° │
│ │
└───────────────────────────────┘
After defining what data we need to show, we can create our models. The OpenWeather API JSON returns something like this
{
"lat": 51.5,
"lon": -0.1,
"timezone": "Europe/London",
"timezone_offset": 3600,
"data": [
{
"dt": 1777449371,
"sunrise": 1777437375,
"sunset": 1777490344,
"temp": 286.42,
"feels_like": 285.32,
"pressure": 1024,
"humidity": 58,
"dew_point": 278.34,
"uvi": 1.55,
"clouds": 0,
"visibility": 10000,
"wind_speed": 8.23,
"wind_deg": 70,
"weather": [
{
"id": 800,
"main": "Clear",
"description": "sky is clear",
"icon": "01d"
}
]
"alerts": [
"8B46C632-DCA7-44D7-8BDF-02445621BAFF",
"29F58A35-BB91-4A73-9F46-9FC64BDF604F",
...
]
}
]
}
The OpenWeather response contains much more information than we need. Rather than decoding the entire JSON, we'll only model the fields our application uses.
Creating the Models
First we have the Weather model
struct Weather: Codable{
var id: Int
var main: String
var description: String
var icon: String
}
also we have the Main model
struct Main: Codable{
let temp: Double
let feels_like: Double
let temp_min: Double
let temp_max: Double
}
Now we can combine the two of them to get the data we need from the OpenWeather API, this is the WeatherResponse object
struct WeatherResponse: Codable {
var weather : [Weather]
var main : Main
}
Building the Service Layer
After creating the model, we can work on the service, the service is part of the data layer, it handles API/NETWORK tasks.
fetchCity and fetchWeather are asynchronous throwing functions. They propagate any networking or decoding errors to the caller, allowing the ViewModel to decide how to handle them.
The WeatherService is responsible for communicating with the OpenWeather API. It converts the city name into coordinates using the Geocoding API, then requests the current weather using those coordinates.
We are going to create the protocol for WeatherService, in that way we can use dependency injection, we can pass the interface via the initializer so you can easily swap it with a mock service during SwiftUI Previews or Unit testing.
protocol WeatherServiceProtocol{
static func convertCityToGeoCodingAPI(city: String) -> String
static func getWeatherAPI(lat: Double, lon: Double) -> String
static func fetchCity(for city: String ) async throws -> CityCoords
func fetchWeather(city: String) async throws -> WeatherResponse
}
We make these helper methods static because they don't depend on any instance-specific state. They simply build URLs based on the provided parameters, so there's no need to create a WeatherService object just to call them.
static func convertCityToGeoCodingAPI(city: String) -> String {
return AppConfig.baseGeoCodingAPIURL + city + "&limit=1&appid=" + AppConfig.apiKey
}
static func getWeatherAPI(lat: Double, lon: Double) -> String {
return AppConfig.baseURL + "\(lat)&lon=\(lon)&appid=" + AppConfig.apiKey
}
now we have the fetchCity and fetchWeather, the fetchCity receives the city name and converts it to coords, the fetchWeather uses the fetchCity function to get the current weather data from the results from fetchCity.
static func fetchCity(for city: String ) async throws -> CityCoords {
//create and validate url
guard let url = URL(string : Self.convertCityToGeoCodingAPI(city: city)) else {
throw NetworkError.invalidURL
}
//Fetch data from the network
let (data, response) = try await URLSession.shared.data(from: url)
//verify http status code
guard let httpResponse = response as? HTTPURLResponse,httpResponse.statusCode == 200 else {
throw NetworkError.invalidResponse
}
//Decode JSON payload
let coordsList = try JSONDecoder().decode([CityCoords].self, from: data)
// 5. Ensure the array isn't empty before picking the first result
guard let coords = coordsList.first else {
throw NetworkError.cityNotFound
}
return coords;
}
func fetchWeather(city: String) async throws -> WeatherResponse {
let coords = try await Self.fetchCity(for: city)
guard let url = URL(string: Self.getWeatherAPI(lat: coords.lat, lon: coords.lon)) else {
throw NetworkError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw NetworkError.invalidResponse
}
let weatherData = try JSONDecoder().decode(WeatherResponse.self, from: data)
return weatherData
}
}
Implementing the ViewModel
Now we can work on the ViewModel; The ViewModel responds to user actions, requests data from the service, stores the screen's state, and exposes formatted data for the View to display.
We use computed properties to send the data formatted to the view so we can keep the view dumb.
The WeatherViewModel conforms to ObservableObject, allowing SwiftUI to observe it for changes. Properties marked with @Published automatically notify the View whenever their values change, causing the UI to refresh.
The @MainActor is used to guarantee the code will run in the main thread, which is mandatory when we are updating the user interface.
import Foundation
import Combine
@MainActor
final class WeatherViewModel: ObservableObject {
//Store state
@Published var weatherResponse : WeatherResponse?
@Published var isLoading = false
@Published var errorMessage: Error?
//Dependencies
private let weatherService : WeatherServiceProtocol
init(weatherService: WeatherServiceProtocol){
self.weatherService = weatherService
}
//private derived data
private var currentWeather: Weather? {
weatherResponse?.weather.first
}
//UI computed properties
var weatherDescription: String {
currentWeather?.description ?? "No Data"
}
var weatherCondition: String {
currentWeather?.main ?? "No Data"
}
var temperatureText : String {
formattedTemperature(weatherResponse?.main.temp)
}
var feelsLikeText : String {
formattedTemperature(weatherResponse?.main.feels_like)
}
var minTemperatureText: String {
formattedTemperature(weatherResponse?.main.temp_min)
}
var maxTemperatureText: String {
formattedTemperature(weatherResponse?.main.temp_max)
}
We use this function to format the temperature and send it to the view; in this way, the view does not have to do anything but display the data.
private func formattedTemperature(_ value: Double?) -> String {
guard let value else {
return "No Data"
}
return String(format: "%.2f°C", value)
}
the last function is the fetchWeather, this one receives a city name as String and invokes the function from the weatherService, this function also controls the different states such as isLoading, errorMessage and weatherResponse.
func fetchWeather(city : String) async {
isLoading = true
errorMessage = nil
defer {
isLoading = false
}
do{
weatherResponse = try await weatherService.fetchWeather(city: city)
} catch {
errorMessage = error
}
}
}
Building the View
Finally, we can build the View.
We create the @State var for getting the user input, As the user types, SwiftUI automatically updates the city state.
We create the weatherService object in the constructor, also the weather ViewModel is created inside the constructor because it needs the reference to the service object.
//
// ContentView.swift
// WeatherApp
//
// Created by Gerald on 23/6/26.
//
import SwiftData
import SwiftUI
struct ContentView: View {
@State private var city = ""
let service : WeatherService
@StateObject private var weatherVM : WeatherViewModel
init(service: WeatherService = WeatherService()) {
self.service = service
self._weatherVM = StateObject(wrappedValue: WeatherViewModel(weatherService: service))
}
var body: some View {
VStack(spacing: 24) {
HStack {
TextField("Enter city", text: $city)
.textFieldStyle(.roundedBorder)
.onSubmit {
fetchWeather()
}
Button("Search") {
fetchWeather()
}
.buttonStyle(.borderedProminent)
}
if weatherVM.temperatureText != "No Data" {
if weatherVM.isLoading {
ProgressView()
}
VStack(spacing: 16) {
Text(weatherVM.weatherCondition)
.font(.largeTitle)
.fontWeight(.bold)
Text(weatherVM.weatherDescription.capitalized)
.font(.title3)
.foregroundStyle(.secondary)
Text(weatherVM.temperatureText)
.font(.system(size: 72, weight: .thin))
HStack(spacing: 40) {
WeatherInfoView(
title: "Feels Like",
value: weatherVM.feelsLikeText
)
WeatherInfoView(
title: "Min",
value: weatherVM.minTemperatureText
)
WeatherInfoView(
title: "Max",
value: weatherVM.maxTemperatureText
)
}
}
.padding()
} else {
ContentUnavailableView(
"No Weather Data",
systemImage: "cloud.sun",
description: Text("Search for a city to see the weather")
)
}
Spacer()
}.alert(
"Error",
isPresented: .constant(weatherVM.errorMessage != nil)
) {
Button("OK") {
weatherVM.errorMessage = nil
}
} message: {
Text(weatherVM.errorMessage?.localizedDescription ?? "")
}
.padding()
}
private func fetchWeather() {
Task {
await weatherVM.fetchWeather(city: city)
}
}
}
struct WeatherInfoView: View {
let title: String
let value: String
var body: some View {
VStack(spacing: 6) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.headline)
}
}
}
#Preview {
ContentView()
}
In this tutorial we built a weather application using SwiftUI and the MVVM architectural pattern.
Along the way we learned how to:
Separate presentation logic from networking.
Use dependency injection through protocols.
Consume a REST API using async/await.
Manage loading and error states.
Keep the View focused only on displaying data.
Although this is a small application, these same architectural principles can be applied to much larger iOS projects.
Source Code
The complete source code for this project is available on GitHub:


Top comments (0)