Header Architecture

Learn to design a header layout like a professional codebase does: a public header that exposes only the intended API, private headers or class extensions for internals, forward declarations that break circular dependencies and minimize compile-time coupling, and disciplined framework import patterns — so that headers describe a deliberate module boundary instead of an accidental one.

Every Objective-C file you write sits inside a layered header strategy whether or not you ever think about it explicitly: a public interface other code is meant to depend on, and implementation details that should not leak into that public surface. Left undesigned, headers tend to accrete imports and exposed members over time until a project's compile-time dependency graph becomes a tangled mess — a change to one header forces a rebuild of far more of the project than it should, two classes end up needing each other's full interface and can't both be compiled, and a "private" detail leaks into a public header because it was simply easier to put it there. This lesson treats header layout as a design discipline in its own right: what belongs in a public header, what belongs in a private header or class extension, how forward declarations break circular dependencies, and how minimizing header coupling keeps a growing codebase's build times and mental model manageable.

Public headers: exposing only the intended API

A class's public .h file is a promise to every other file that imports it: here is what you may rely on. Every property, method, and protocol conformance declared in a public header becomes part of the class's contract with the rest of the codebase — and, once other code depends on it, becomes something you cannot casually change or remove without also changing every caller.

The discipline here is restraint: a public header should declare exactly the surface area other code legitimately needs to use this class correctly, and nothing more. An internal helper method used only by the class's own implementation, an ivar-like piece of state that exists purely to support the class's own logic, or a property that only makes sense to mutate from inside the class itself — none of these belong in the public header, even though it is often the path of least resistance to just put everything there during initial development.

Private headers and class extensions for internal-only members

Objective-C gives you two closely related tools for keeping implementation details out of the public header while still having them exist as real, typed properties and methods. The first, and by far the more common, is the class extension — an anonymous category, written as @interface ClassName () ... @end directly at the top of the .m file, that adds properties, methods, or protocol conformances visible only within that implementation file. This is exactly the mechanism behind the "public readonly, private readwrite" property pattern covered in the "Class Extensions" module, and it's the default choice for most internal-only members.

The second tool, used less often but valuable for internal members that genuinely need to be shared across more than one implementation file (for example, a cluster of closely related classes in the same subsystem that legitimately need to call each other's internal-only methods), is a separate private header, conventionally named ClassName+Private.h. Unlike a class extension, a private header is a real file that can be #import'd — but only by the small set of implementation files inside the same subsystem that are meant to see those internals, never by the project's public headers or by unrelated code elsewhere in the project. The naming convention (+Private) signals its restricted intent clearly to anyone who encounters it.

Forward declarations to break circular dependencies

A circular dependency happens when class A's header needs to reference class B, and class B's header needs to reference class A right back — a parent view controller that holds a child, and a child that holds a weak reference back to its parent, is a completely ordinary and common shape for this to arise in. If both headers try to #import each other's full header, the compiler runs into a genuine problem: processing A.h requires B.h, which requires A.h again, which is still being processed — an infinite loop the preprocessor has to break somehow, usually leaving one side with an incomplete, confusing view of the other's interface.

The fix is @class: instead of #import "B.h" inside A.h, A.h writes @class B; and declares its property or parameter as B * without needing B's actual interface at that point — the compiler only needs to know B is a class to accept a pointer to it. B.h does the same in reverse for A. Neither header needs the other's real interface merely to declare a pointer-typed member; the real #import "B.h" and #import "A.h" move into each class's own .m file, where actual method calls (which do require knowing the other class's real interface) happen. This breaks the cycle cleanly, since neither header depends on the other's full content anymore, only on the mere existence of a class by that name.

Framework imports: umbrella vs individual submodule imports

A framework typically exposes an umbrella header — #import <UIKit/UIKit.h> — that itself imports every individual header the framework provides, so that a single import statement gives you the framework's entire public surface. This is convenient and is what most application code reaches for by default, since needing "some of UIKit" and "all of UIKit" usually cost about the same in practice for an app target.

Individual submodule imports — #import <UIKit/UIButton.h> instead of the whole umbrella — are more precise and occasionally worth reaching for deliberately: a header that genuinely only needs one specific type from a large framework, especially in a context where build time or dependency footprint is being watched closely (a framework target of your own that should not casually depend on all of another large framework, for instance), can import just that one piece. In everyday application code this level of precision is usually not worth the friction it adds, but understanding that the umbrella header is just a convenience wrapper around individually importable pieces — not the only way in — matters once you're the one designing a framework's own header layout.

Minimizing header coupling as a design principle

Every header your file imports is a compile-time dependency: when that imported header changes, every file that (directly or transitively) imports it needs to be recompiled, and the deeper and wider that import chain, the more of the project a single change can force a rebuild of. A header that imports five other headers, each of which imports several more, quietly creates a dependency footprint far larger than the one class's own logic would suggest — and in a large project, this compounds into build times that grow much faster than the code itself does.

The governing rule, already used throughout this lesson without naming it directly until now, is: prefer a forward declaration (@class Foo;) inside a header wherever a pointer type is all that's needed, and reserve a full #import of another class's real header for the .m file, where actual method calls require it. Applied consistently across a whole codebase, this keeps each header's own dependency footprint close to the minimum the header itself actually requires, rather than the (much larger) footprint of everything every file that uses this class might eventually need — that larger footprint belongs in .m files, spread out across exactly the places that actually need it, not concentrated in the header where it taxes every single importer.

API exposure vs implementation details, as a module-boundary discipline

Pulling the previous sections together: a well-designed header layout treats the public .h as a genuine module boundary, not an accident of "whatever the compiler happened to require me to put where." Public headers describe API — the intended, supported way other code interacts with this class. Private headers and class extensions hold implementation details — state and behavior that exist to make the public API work correctly, but that no other part of the codebase should be relying on directly, because they can change at any time without that being considered a breaking change.

Treating this boundary carelessly has a real cost beyond just aesthetics: once something is exposed in a public header, other code will eventually depend on it, whether or not that was the intent — and un-exposing it later becomes a breaking change that requires hunting down and updating every caller. Designing the boundary deliberately, from the start, is far cheaper than trying to narrow an over-broad public header after the fact once a dozen other files have quietly come to depend on an implementation detail that was never meant to be part of the contract.

Forward declarations breaking a circular dependency

Both headers only need @class for each other — neither header's pointer-typed property or parameter requires the other class's full interface. The actual #import of each class's header moves to the .m files, exactly where real method calls on those objects require the full interface. This is the same circular-reference shape a delegate relationship creates, and the same @class fix applies to both.

// ParentController.h
@class ChildController; // forward declaration — no need for ChildController's full interface here

@interface ParentController : UIViewController
@property (nonatomic, strong) ChildController *child;
- (void)childDidFinish:(ChildController *)child;
@end

// ChildController.h
@class ParentController; // forward declaration back the other way

@interface ChildController : UIViewController
@property (nonatomic, weak) ParentController *parent; // weak: avoids a retain cycle too
@end

// ParentController.m — the real #import lives here, where method calls actually happen
#import "ParentController.h"
#import "ChildController.h"

@implementation ParentController
- (void)setup {
  self.child = [ChildController new];
  self.child.parent = self; // this line needs ChildController's real interface — hence the .m import
}
- (void)childDidFinish:(ChildController *)child {
  NSLog(@"Child finished: %@", child);
}
@end

Code Examples

Callers of UserProfileView see exactly three things they can rely on: a readonly displayName they can read but not set directly, and two methods for configuring and refreshing the view. Nothing about how the view lays out its subviews, what internal caching it does, or any other implementation detail leaks into this file at all — this is the entire contract other code should ever depend on.

@property (nonatomic, copy, readonly) NSString *displayName;

Every member declared here — nameLabel, avatarImageView, cachedUser, and the widened writable displayName — exists purely to support this view's own internal logic. None of it is anything another file legitimately needs to touch directly, so none of it appears in the public header. Any code outside this .m file that tried to access self.nameLabel on a UserProfileView instance would get a compile error, exactly as intended.

@interface UserProfileView () PlaylistScheduler is a different class than PlaylistEngine, but it's part of the same closely-related subsystem and genuinely needs to call an internal-only method that no unrelated code in the project should be able to call. A plain class extension in PlaylistEngine.m wouldn't be visible to PlaylistScheduler.m at all — extensions are scoped to a single implementation file — so a shared +Private.h header is the right tool here, imported only by the small set of files inside this subsystem that are meant to see it, never from a public header or from unrelated code elsewhere.

SettingsViewController is ordinary application code that will end up using a wide range of UIKit — the umbrella #import <UIKit/UIKit.h> is the right, unremarkable default. MinimalWidgetFactory, by contrast, sits inside a hypothetical internal framework target that deliberately wants a narrow, auditable dependency footprint, so it imports only the one UIKit piece it actually needs rather than the entire framework's umbrella header. Most everyday application code should default to umbrella imports; individual submodule imports are a deliberate choice reserved for contexts where dependency footprint is being watched closely, such as your own framework or library targets.

// UserProfileView.h — public
@interface UserProfileView : UIView
@property (nonatomic, copy, readonly) NSString *displayName;
- (void)configureWithUser:(User *)user;
- (void)refresh;
@end
// UserProfileView.m
@interface UserProfileView ()
@property (nonatomic, copy, readwrite) NSString *displayName; // widened for internal use
@property (nonatomic, strong) UILabel *nameLabel;
@property (nonatomic, strong) UIImageView *avatarImageView;
@property (nonatomic, strong) User *cachedUser;
@end

@implementation UserProfileView

- (void)configureWithUser:(User *)user {
  self.cachedUser = user;
  self.displayName = user.fullName; // legal here — this file widened it to readwrite
  self.nameLabel.text = user.fullName;
  [self.avatarImageView setImageFromURL:user.avatarURL];
}

- (void)refresh {
  if (self.cachedUser) {
    [self configureWithUser:self.cachedUser];
  }
}

@end
// PlaylistEngine+Private.h — imported only by the playlist subsystem's own .m files
#import "PlaylistEngine.h"

@interface PlaylistEngine ()
@property (nonatomic, strong) NSMutableArray<Track *> *internalQueue;
- (void)rebuildQueueFromSource:(PlaylistSource *)source; // internal-only method
@end

// PlaylistScheduler.m — a sibling class in the same subsystem, needs internal access
#import "PlaylistEngine+Private.h" // NOT PlaylistEngine.h alone — needs the internals too

@implementation PlaylistScheduler
- (void)advanceQueue {
  [self.engine rebuildQueueFromSource:self.currentSource]; // only visible via the +Private header
}
@end
// Typical application code — the umbrella import is simplest and entirely normal here
#import <UIKit/UIKit.h>

@interface SettingsViewController : UIViewController
@property (nonatomic, strong) UISwitch *notificationsSwitch;
@end

// Inside a small internal framework target that should not casually depend on
// all of UIKit — an individual submodule import is more precise
#import <UIKit/UIButton.h>

@interface MinimalWidgetFactory : NSObject
+ (UIButton *)makeStandardButton;
@end

The layered header strategy

A class's public .h exposes only its intended API to the rest of the codebase. Internal-only members live in a private header or class extension, visible just to the implementation. The .m file is where the real work happens, and where full imports of other classes' headers belong when actual method calls require them.

Public Header
   ↓
Public API
   ↓
Private Header / Extension
   ↓
Implementation (.m)

Where this shows up

Every non-trivial Objective-C codebase eventually runs into all three of these scenarios: a parent/child or delegate relationship that would create a circular #import without forward declarations, a class with real internal state that has no business being visible outside its own .m file, and a build that gets slower over time as headers accumulate unnecessary imports nobody ever pruned. Disciplined header architecture — forward declarations by default in headers, class extensions or private headers for internals, full imports confined to .m files — is one of the clearest signals of a mature codebase, and it directly determines how painful the project's incremental build times become as it grows. See the "Objective-C Compiler and Preprocessor Details" module for the preprocessor mechanics (#import vs @import, header guards) underneath all of this, the "Class Extensions" module for how the anonymous-category mechanism itself works, and "Modules and Frameworks" for how this same layered thinking extends to an entire framework's public surface.

// ParentController.h #import "ChildController.h" // full import creates a cycle @interface ParentController : UIViewController @property (nonatomic, strong) ChildController *child; @end // ChildController.h #import "ParentController.h" // full import right back — circular @interface ChildController : UIViewController @property (nonatomic, weak) ParentController *parent; @end // Result: build errors from the circular #import chain, or one side silently // ending up with an incomplete view of the other's interface.

// ParentController.h @class ChildController; // forward declaration breaks the cycle @interface ParentController : UIViewController @property (nonatomic, strong) ChildController *child; @end // ChildController.h @class ParentController; @interface ChildController : UIViewController @property (nonatomic, weak) ParentController *parent; @end // Real #import "ChildController.h" and #import "ParentController.h" move into // each class's own .m file instead.

Two headers that fully #import each other create a genuine circular dependency the preprocessor cannot cleanly resolve — one side ends up processed while still mid-way through processing the other, typically leaving it with missing or incomplete declarations and confusing compile errors. Replacing the header-to-header #import with @class forward declarations on both sides breaks the cycle entirely, since neither header actually needs the other's full interface just to declare a pointer-typed member.

// AccountManager.h — public header exposing internal implementation details @interface AccountManager : NSObject @property (nonatomic, strong) NSMutableDictionary *rawSessionCache; // internal detail, exposed publicly @property (nonatomic, strong) NSTimer *refreshTimer; // internal detail, exposed publicly - (void)_internalRefreshTokenIfNeeded; // clearly meant to be private, but declared publicly anyway - (void)login; - (void)logout; @end

// AccountManager.h — public header, API only @interface AccountManager : NSObject - (void)login; - (void)logout; @property (nonatomic, readonly) BOOL isLoggedIn; @end // AccountManager.m — internals moved to a class extension @interface AccountManager () @property (nonatomic, strong) NSMutableDictionary *rawSessionCache; @property (nonatomic, strong) NSTimer *refreshTimer; @property (nonatomic, readwrite) BOOL isLoggedIn; - (void)_internalRefreshTokenIfNeeded; @end

Putting rawSessionCache, refreshTimer, and an internal-only refresh method directly in the public header unintentionally exposes implementation details as if they were part of the class's supported API — other code can now read and even mutate rawSessionCache directly, and every one of these becomes something that can't be changed later without risking breaking a caller who came to depend on it. Moving them into a class extension in the .m file keeps the public header limited to login, logout, and a readonly isLoggedIn status — the actual intended surface.

[Multiple Choice] Class A and Class B each need a strong or weak pointer-typed property referencing the other. What is the correct way to declare this in both headers without creating a circular #import? A. Both headers fully #import each other; the preprocessor resolves the cycle automatically B. Both headers use @class forward declarations for each other; the real #import moves to each class's .m file C. Only one of the two headers should declare a property of the other's type D. Use @dynamic instead of a property in both classes

B. Both headers use @class forward declarations for each other; the real #import moves to each class's .m file — @class forward declarations are sufficient for a pointer-typed property or parameter and require nothing from the other class's real interface. This breaks the circular dependency, and the full #import of each class's real header belongs in the .m file where actual method calls happen.

[True / False] Every header a file imports is a compile-time dependency, so minimizing unnecessary imports in headers (in favor of forward declarations) helps keep incremental build times manageable as a project grows. A. True B. False

A. True — True. A header's imports ripple outward to every file that imports it, directly or transitively — deep, unnecessary import chains in headers meaningfully slow incremental builds as a project grows, which is exactly why forward declarations in headers (with full imports confined to .m files) matter.

[Multiple Choice] A class has several internal-only properties and methods that no other file should depend on. What is the standard first choice for hiding them from the public header? A. Declare them in the public header but prefix their names with an underscore B. Put them in a class extension (@interface ClassName () ... @end) inside the .m file C. Declare them as @dynamic D. Leave them in the public header and trust that other developers won't use them

B. Put them in a class extension (@interface ClassName () ... @end) inside the .m file — A class extension is the default, most common tool for internal-only members — it lets them exist as real, typed properties and methods, visible only within that class's own .m file, without appearing in the public header at all.

[Multiple Choice] When is a ClassName+Private.h header a better choice than a plain class extension for internal members? A. Always — private headers are strictly superior to class extensions B. Never — class extensions can always do everything a private header can C. When more than one implementation file within the same subsystem genuinely needs access to those internals, since a class extension is scoped to a single .m file D. Only when the class is a subclass of NSObject

C. When more than one implementation file within the same subsystem genuinely needs access to those internals, since a class extension is scoped to a single .m file — A class extension's members are visible only within the single .m file that declares it. When a closely related sibling class in the same subsystem legitimately needs access to the same internals, a shared ClassName+Private.h header — imported only by that subsystem's own files — is the appropriate tool instead.

[Multiple Choice] What is the risk of declaring an internal implementation-detail property directly in a class's public header instead of a class extension? A. The property will not compile correctly B. Nothing — public and private declarations behave identically at runtime C. Other code will eventually come to depend on it, making it a breaking change to remove or change later, even though it was never meant to be part of the supported API D. It will automatically become a class property instead of an instance property

C. Other code will eventually come to depend on it, making it a breaking change to remove or change later, even though it was never meant to be part of the supported API — Anything declared in a public header is visible to, and eventually likely to be used by, other code — whether or not that was the intent. Once that happens, narrowing or removing it later becomes a breaking change, which is precisely the cost of not designing the public/private boundary deliberately from the start.

Two classes, Order and Invoice, need references to each other: Order holds a strong reference to its Invoice, and Invoice holds a weak back-reference to the Order it belongs to. Write both public headers using forward declarations to avoid a circular #import, then write the relevant piece of Order.m showing where the real #import "Invoice.h" belongs and how it's used.

// Order.h @class Invoice; @interface Order : NSObject @property (nonatomic, strong) Invoice *invoice; - (void)markInvoicePaid; @end // Invoice.h @class Order; @interface Invoice : NSObject @property (nonatomic, weak) Order *order; // weak: avoids a retain cycle with Order's strong reference @property (nonatomic, assign) BOOL paid; @end // Order.m #import "Order.h" #import "Invoice.h" // real import needed here — this file calls a method on Invoice @implementation Order - (void)markInvoicePaid { self.invoice.paid = YES; // requires Invoice's real interface, hence the full import above } @end

Neither header needs the other's full interface to declare a pointer-typed property — @class is enough in both .h files. The real #import for each only becomes necessary in the .m file that actually calls methods on the other class.